From bacb4596b754ba1b0a7b3c293c052ca5d6a64458 Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Fri, 14 Feb 2025 18:34:30 -0600 Subject: [PATCH] feat(portal): Internet Sites (#6905) Related #6834 Co-authored-by: Jamil Bou Kheir --- .../domain/auth/adapters/okta/api_client.ex | 8 +- .../lib/domain/billing/event_handler.ex | 7 +- elixir/apps/domain/lib/domain/gateways.ex | 25 +- .../apps/domain/lib/domain/gateways/group.ex | 2 + .../lib/domain/gateways/group/changeset.ex | 3 + .../domain/lib/domain/gateways/group/query.ex | 17 + elixir/apps/domain/lib/domain/repo/filter.ex | 4 + elixir/apps/domain/lib/domain/resources.ex | 29 +- .../domain/lib/domain/resources/connection.ex | 2 +- .../domain/resources/connection/changeset.ex | 7 +- .../lib/domain/resources/resource/query.ex | 17 + .../20240808165513_add_internet_resources.exs | 7 - ...01174525_add_gateway_groups_managed_by.exs | 9 + .../20250214183755_create_uuid_extension.exs | 11 + .../20250214183914_create_internet_site.exs | 44 + elixir/apps/domain/priv/repo/seeds.exs | 13 +- .../jobs/sync_directory_test.exs | 12 +- .../jumpcloud/jobs/sync_directory_test.exs | 12 +- .../jobs/sync_directory_test.exs | 12 +- .../okta/jobs/sync_directory_test.exs | 12 +- .../apps/domain/test/domain/gateways_test.exs | 8 + .../domain/test/support/fixtures/gateways.ex | 3 +- .../web/lib/web/components/core_components.ex | 3 +- .../web/lib/web/components/page_components.ex | 12 +- elixir/apps/web/lib/web/live/policies/new.ex | 13 +- .../apps/web/lib/web/live/resources/edit.ex | 3 +- .../apps/web/lib/web/live/resources/index.ex | 23 +- .../apps/web/lib/web/live/resources/show.ex | 2 +- elixir/apps/web/lib/web/live/sign_up.ex | 18 +- .../web/lib/web/live/sites/gateways/index.ex | 19 +- elixir/apps/web/lib/web/live/sites/index.ex | 210 ++++- elixir/apps/web/lib/web/live/sites/show.ex | 193 +++- .../apps/web/test/web/live/sign_up_test.exs | 10 +- .../web/test/web/live/sites/index_test.exs | 48 + .../web/test/web/live/sites/show_test.exs | 866 +++++++++++++----- 35 files changed, 1373 insertions(+), 311 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20241001174525_add_gateway_groups_managed_by.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20250214183755_create_uuid_extension.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20250214183914_create_internet_site.exs diff --git a/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex index b1b08f78f..e4b07e8d2 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/okta/api_client.ex @@ -100,13 +100,19 @@ defmodule Domain.Auth.Adapters.Okta.APIClient do end end + if Mix.env() == :test do + def throttle, do: :ok + else + def throttle, do: :timer.sleep(:timer.seconds(1)) + end + # TODO: Need to catch 401/403 specifically when error message is in header defp list(uri, headers, api_token) do headers = headers ++ [{"Authorization", "Bearer #{api_token}"}] request = Finch.build(:get, uri, headers) # Crude request throttle, revisit for https://github.com/firezone/firezone/issues/6793 - :timer.sleep(:timer.seconds(1)) + throttle() with {:ok, %Finch.Response{headers: headers, body: response, status: status}} when status in 200..299 <- Finch.request(request, @pool_name), diff --git a/elixir/apps/domain/lib/domain/billing/event_handler.ex b/elixir/apps/domain/lib/domain/billing/event_handler.ex index c33c3b69d..e6e6b92a2 100644 --- a/elixir/apps/domain/lib/domain/billing/event_handler.ex +++ b/elixir/apps/domain/lib/domain/billing/event_handler.ex @@ -296,10 +296,13 @@ defmodule Domain.Billing.EventHandler do provider_identifier_confirmation: metadata["account_admin_email"] || account_email }) - {:ok, _resource} = Domain.Resources.create_internet_resource(account) - {:ok, _gateway_group} = Domain.Gateways.create_group(account, %{name: "Default Site"}) + {:ok, internet_gateway_group} = Domain.Gateways.create_internet_group(account) + + {:ok, _resource} = + Domain.Resources.create_internet_resource(account, internet_gateway_group) + :ok else {:error, %Ecto.Changeset{errors: [{:slug, {"has already been taken", _}} | _]}} -> diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 42ed582cb..ad9df9ad7 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -36,6 +36,14 @@ defmodule Domain.Gateways do end end + def fetch_internet_group(%Accounts.Account{} = account) do + Group.Query.not_deleted() + |> Group.Query.by_managed_by(:system) + |> Group.Query.by_account_id(account.id) + |> Group.Query.by_name("Internet") + |> Repo.fetch(Group.Query, []) + end + def list_groups(%Auth.Subject{} = subject, opts \\ []) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do Group.Query.not_deleted() @@ -46,12 +54,14 @@ defmodule Domain.Gateways do def all_groups!(%Auth.Subject{} = subject) do Group.Query.not_deleted() + |> Group.Query.by_managed_by(:account) |> Authorizer.for_subject(subject) |> Repo.all() end def all_groups_for_account!(%Accounts.Account{} = account) do Group.Query.not_deleted() + |> Group.Query.by_managed_by(:account) |> Group.Query.by_account_id(account.id) |> Repo.all() end @@ -78,13 +88,24 @@ defmodule Domain.Gateways do |> Repo.insert() end + def create_internet_group(%Accounts.Account{} = account) do + attrs = %{ + "name" => "Internet", + "managed_by" => "system" + } + + account + |> Group.Changeset.create(attrs) + |> Repo.insert() + end + def change_group(%Group{} = group, attrs \\ %{}) do group |> Repo.preload(:account) |> Group.Changeset.update(attrs) end - def update_group(%Group{} = group, attrs, %Auth.Subject{} = subject) do + def update_group(%Group{managed_by: :account} = group, attrs, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do Group.Query.not_deleted() |> Group.Query.by_id(group.id) @@ -108,7 +129,7 @@ defmodule Domain.Gateways do end end - def delete_group(%Group{} = group, %Auth.Subject{} = subject) do + def delete_group(%Group{managed_by: :account} = group, %Auth.Subject{} = subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do Group.Query.not_deleted() |> Group.Query.by_id(group.id) diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index dd72b75a8..c942535db 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -4,6 +4,8 @@ defmodule Domain.Gateways.Group do schema "gateway_groups" do field :name, :string + field :managed_by, Ecto.Enum, values: ~w[account system]a + belongs_to :account, Domain.Accounts.Account has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id, where: [deleted_at: nil] diff --git a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex index 2b4dc07cd..c3fa8ccfa 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex @@ -8,6 +8,7 @@ defmodule Domain.Gateways.Group.Changeset do def create(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do %Gateways.Group{account: account} |> changeset(attrs) + |> put_default_value(:managed_by, :account) |> put_change(:account_id, account.id) |> put_subject_trail(:created_by, subject) end @@ -15,6 +16,8 @@ defmodule Domain.Gateways.Group.Changeset do def create(%Accounts.Account{} = account, attrs) do %Gateways.Group{account: account} |> changeset(attrs) + |> cast(attrs, ~w[managed_by]a) + |> put_default_value(:managed_by, :account) |> put_change(:account_id, account.id) |> put_subject_trail(:created_by, :system) end diff --git a/elixir/apps/domain/lib/domain/gateways/group/query.ex b/elixir/apps/domain/lib/domain/gateways/group/query.ex index 3595be530..ea3b16c7e 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/query.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/query.ex @@ -18,6 +18,14 @@ defmodule Domain.Gateways.Group.Query do where(queryable, [groups: groups], groups.account_id == ^account_id) end + def by_managed_by(queryable, managed_by) do + where(queryable, [groups: groups], groups.managed_by == ^managed_by) + end + + def by_name(queryable, name) do + where(queryable, [groups: groups], groups.name == ^name) + end + # Pagination @impl Domain.Repo.Query @@ -40,10 +48,19 @@ defmodule Domain.Gateways.Group.Query do name: :deleted?, type: :boolean, fun: &filter_deleted/1 + }, + %Domain.Repo.Filter{ + name: :managed_by, + type: :string, + fun: &filter_managed_by/2 } ] def filter_deleted(queryable) do {queryable, dynamic([groups: groups], not is_nil(groups.deleted_at))} end + + def filter_managed_by(queryable, managed_by) do + {queryable, dynamic([groups: groups], groups.managed_by == ^managed_by)} + end end diff --git a/elixir/apps/domain/lib/domain/repo/filter.ex b/elixir/apps/domain/lib/domain/repo/filter.ex index 4a29c9495..37948d07d 100644 --- a/elixir/apps/domain/lib/domain/repo/filter.ex +++ b/elixir/apps/domain/lib/domain/repo/filter.ex @@ -227,6 +227,10 @@ defmodule Domain.Repo.Filter do not (is_nil(from) and is_nil(to)) end + defp value_type_valid?({:list, type}, {:not_in, values}) when is_list(values) do + Enum.all?(values, &value_type_valid?(type, &1)) + end + defp value_type_valid?({:list, type}, values) when is_list(values) do Enum.all?(values, &value_type_valid?(type, &1)) end diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index a11dd27d4..bdac1b779 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -23,6 +23,16 @@ defmodule Domain.Resources do end end + def fetch_internet_resource(%Auth.Subject{} = subject, opts \\ []) do + with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do + Resource.Query.all() + |> Resource.Query.by_account_id(subject.account.id) + |> Resource.Query.by_type(:internet) + |> Authorizer.for_subject(Resource, subject) + |> Repo.fetch(Resource.Query, opts) + end + end + def fetch_resource_by_id_or_persistent_id(id, %Auth.Subject{} = subject, opts \\ []) do required_permissions = {:one_of, @@ -221,8 +231,18 @@ defmodule Domain.Resources do end end - def create_internet_resource(%Accounts.Account{} = account) do - attrs = %{type: :internet, name: "Internet"} + def create_internet_resource(%Accounts.Account{} = account, %Gateways.Group{} = group) do + attrs = %{ + type: :internet, + name: "Internet", + connections: %{ + group.id => %{ + gateway_group_id: group.id, + enabled: true + } + } + } + changeset = Resource.Changeset.create(account, attrs) with {:ok, resource} <- Repo.insert(changeset) do @@ -316,6 +336,11 @@ defmodule Domain.Resources do |> delete_connections(subject) end + def delete_connections_for(%Resource{} = resource, %Auth.Subject{} = subject) do + Connection.Query.by_resource_id(resource.id) + |> delete_connections(subject) + end + defp delete_connections(queryable, subject) do with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do {count, nil} = diff --git a/elixir/apps/domain/lib/domain/resources/connection.ex b/elixir/apps/domain/lib/domain/resources/connection.ex index 5c3f7caf9..a27daf202 100644 --- a/elixir/apps/domain/lib/domain/resources/connection.ex +++ b/elixir/apps/domain/lib/domain/resources/connection.ex @@ -6,7 +6,7 @@ defmodule Domain.Resources.Connection do belongs_to :resource, Domain.Resources.Resource, primary_key: true belongs_to :gateway_group, Domain.Gateways.Group, primary_key: true - field :created_by, Ecto.Enum, values: ~w[actor identity]a + field :created_by, Ecto.Enum, values: ~w[actor identity system]a belongs_to :created_by_identity, Domain.Auth.Identity belongs_to :created_by_actor, Domain.Actors.Actor diff --git a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex index d902589ff..5697f8d68 100644 --- a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex @@ -6,11 +6,16 @@ defmodule Domain.Resources.Connection.Changeset do @required_fields @fields def changeset(account_id, connection, attrs, %Auth.Subject{} = subject) do - changeset(account_id, connection, attrs) + base_changeset(account_id, connection, attrs) |> put_subject_trail(:created_by, subject) end def changeset(account_id, connection, attrs) do + base_changeset(account_id, connection, attrs) + |> put_change(:created_by, :system) + end + + defp base_changeset(account_id, connection, attrs) do connection |> cast(attrs, @fields) |> validate_required(@required_fields) diff --git a/elixir/apps/domain/lib/domain/resources/resource/query.ex b/elixir/apps/domain/lib/domain/resources/resource/query.ex index d40e5ea82..c908d87dd 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/query.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/query.ex @@ -26,6 +26,10 @@ defmodule Domain.Resources.Resource.Query do where(queryable, [resources: resources], resources.id == ^id) end + def by_type(queryable, type) do + where(queryable, [resources: resources], resources.type == ^type) + end + def by_id_or_persistent_id(queryable, id) do where(queryable, [resources: resources], resources.id == ^id) |> or_where( @@ -166,6 +170,11 @@ defmodule Domain.Resources.Resource.Query do name: :deleted?, type: :boolean, fun: &filter_deleted/1 + }, + %Domain.Repo.Filter{ + name: :type, + type: {:list, :string}, + fun: &filter_by_type/2 } ] @@ -186,4 +195,12 @@ defmodule Domain.Resources.Resource.Query do def filter_deleted(queryable) do {queryable, dynamic([resources: resources], not is_nil(resources.deleted_at))} end + + def filter_by_type(queryable, {:not_in, types}) do + {queryable, dynamic([resources: resources], resources.type not in ^types)} + end + + def filter_by_type(queryable, types) do + {queryable, dynamic([resources: resources], resources.type in ^types)} + end end diff --git a/elixir/apps/domain/priv/repo/migrations/20240808165513_add_internet_resources.exs b/elixir/apps/domain/priv/repo/migrations/20240808165513_add_internet_resources.exs index aad093909..6c9ed77a3 100644 --- a/elixir/apps/domain/priv/repo/migrations/20240808165513_add_internet_resources.exs +++ b/elixir/apps/domain/priv/repo/migrations/20240808165513_add_internet_resources.exs @@ -19,12 +19,5 @@ defmodule Domain.Repo.Migrations.AddInternetResources do name: "unique_internet_resource_per_account" ) ) - - # Manual migration that needs to be run after deployment - # (Domain.Accounts.Account.Query.not_deleted() - # |> Domain.Repo.all() - # |> Enum.each(fn account -> - # Domain.Resources.create_internet_resource(account) - # end)) end end diff --git a/elixir/apps/domain/priv/repo/migrations/20241001174525_add_gateway_groups_managed_by.exs b/elixir/apps/domain/priv/repo/migrations/20241001174525_add_gateway_groups_managed_by.exs new file mode 100644 index 000000000..80c7e39af --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20241001174525_add_gateway_groups_managed_by.exs @@ -0,0 +1,9 @@ +defmodule Domain.Repo.Migrations.AddGatewayGroupsManagedBy do + use Ecto.Migration + + def change do + alter table(:gateway_groups) do + add(:managed_by, :string, null: false, default: "account") + end + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20250214183755_create_uuid_extension.exs b/elixir/apps/domain/priv/repo/migrations/20250214183755_create_uuid_extension.exs new file mode 100644 index 000000000..04519567d --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250214183755_create_uuid_extension.exs @@ -0,0 +1,11 @@ +defmodule Domain.Repo.Migrations.CreateUuidExtension do + use Ecto.Migration + + def up do + execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"") + end + + def down do + execute("DROP EXTENSION IF EXISTS \"uuid-ossp\"") + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20250214183914_create_internet_site.exs b/elixir/apps/domain/priv/repo/migrations/20250214183914_create_internet_site.exs new file mode 100644 index 000000000..ebbf53293 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250214183914_create_internet_site.exs @@ -0,0 +1,44 @@ +defmodule Domain.Repo.Migrations.CreateInternetSite do + use Ecto.Migration + + def up do + execute(""" + INSERT INTO gateway_groups ( + id, + account_id, + name, + created_by, + managed_by, + inserted_at, + updated_at + ) + SELECT + uuid_generate_v4(), + id, + 'Internet', + 'system', + 'system', + NOW(), + NOW() + FROM accounts + WHERE deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM gateway_groups + WHERE gateway_groups.account_id = accounts.id + AND gateway_groups.name = 'Internet' + AND gateway_groups.created_by = 'system' + AND gateway_groups.managed_by = 'system') + """) + end + + def down do + execute(""" + DELETE FROM gateway_groups + WHERE name = 'Internet' + AND created_by = 'system' + AND managed_by = 'system' + AND account_id IN (SELECT id FROM accounts WHERE deleted_at IS NULL); + """) + end +end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 4bd2350e6..f0eb82197 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -73,9 +73,16 @@ end IO.puts("") -for account <- [account, other_account] do - Domain.Resources.create_internet_resource(account) -end +{:ok, internet_gateway_group} = + Gateways.create_internet_group(account) + +{:ok, other_internet_gateway_group} = + Gateways.create_internet_group(other_account) + +Domain.Resources.create_internet_resource(account, internet_gateway_group) +Domain.Resources.create_internet_resource(other_account, other_internet_gateway_group) + +IO.puts("") {:ok, everyone_group} = Domain.Actors.create_managed_group(account, %{ diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs index d53f62945..ccb1a4f60 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs/sync_directory_test.exs @@ -823,7 +823,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do cancel_bypass_expectations_check(bypass) end - test "sends email on failed directory sync", %{account: account} do + test "sends email on failed directory sync", %{account: account, provider: provider} do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) _identity = Fixtures.Auth.create_identity(account: account, actor: actor) @@ -882,10 +882,12 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs.SyncDirectoryTest do end) end - for _n <- 1..10 do - {:ok, pid} = Task.Supervisor.start_link() - assert execute(%{task_supervisor: pid}) == :ok - end + provider + |> Ecto.Changeset.change(last_syncs_failed: 9) + |> Repo.update!() + + {:ok, pid} = Task.Supervisor.start_link() + assert execute(%{task_supervisor: pid}) == :ok assert_email_sent(fn email -> assert email.subject == "Firezone Identity Provider Sync Error" diff --git a/elixir/apps/domain/test/domain/auth/adapters/jumpcloud/jobs/sync_directory_test.exs b/elixir/apps/domain/test/domain/auth/adapters/jumpcloud/jobs/sync_directory_test.exs index 08c47ec28..d85e0653d 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/jumpcloud/jobs/sync_directory_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/jumpcloud/jobs/sync_directory_test.exs @@ -562,7 +562,7 @@ defmodule Domain.Auth.Adapters.JumpCloud.Jobs.SyncDirectoryTest do cancel_bypass_expectations_check(bypass) end - test "sends email on failed directory sync", %{account: account} do + test "sends email on failed directory sync", %{account: account, provider: provider} do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) _identity = Fixtures.Auth.create_identity(account: account, actor: actor) @@ -582,10 +582,12 @@ defmodule Domain.Auth.Adapters.JumpCloud.Jobs.SyncDirectoryTest do end) end - for _n <- 1..10 do - {:ok, pid} = Task.Supervisor.start_link() - assert execute(%{task_supervisor: pid}) == :ok - end + provider + |> Ecto.Changeset.change(last_syncs_failed: 9) + |> Repo.update!() + + {:ok, pid} = Task.Supervisor.start_link() + assert execute(%{task_supervisor: pid}) == :ok assert_email_sent(fn email -> assert email.subject == "Firezone Identity Provider Sync Error" diff --git a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs/sync_directory_test.exs b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs/sync_directory_test.exs index 2bbb5544a..4fd7c2e9c 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs/sync_directory_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/microsoft_entra/jobs/sync_directory_test.exs @@ -494,7 +494,7 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs.SyncDirectoryTest do cancel_bypass_expectations_check(bypass) end - test "sends email on failed directory sync", %{account: account} do + test "sends email on failed directory sync", %{account: account, provider: provider} do bypass = Bypass.open() MicrosoftEntraDirectory.override_endpoint_url("http://localhost:#{bypass.port}/") @@ -510,10 +510,12 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs.SyncDirectoryTest do end) end - for _n <- 1..10 do - {:ok, pid} = Task.Supervisor.start_link() - assert execute(%{task_supervisor: pid}) == :ok - end + provider + |> Ecto.Changeset.change(last_syncs_failed: 9) + |> Repo.update!() + + {:ok, pid} = Task.Supervisor.start_link() + assert execute(%{task_supervisor: pid}) == :ok assert_email_sent(fn email -> assert email.subject == "Firezone Identity Provider Sync Error" diff --git a/elixir/apps/domain/test/domain/auth/adapters/okta/jobs/sync_directory_test.exs b/elixir/apps/domain/test/domain/auth/adapters/okta/jobs/sync_directory_test.exs index 73123e061..32e25a1c8 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/okta/jobs/sync_directory_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/okta/jobs/sync_directory_test.exs @@ -790,6 +790,7 @@ defmodule Domain.Auth.Adapters.Okta.Jobs.SyncDirectoryTest do test "sends email on failed directory sync", %{ account: account, + provider: provider, bypass: bypass } do actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) @@ -812,10 +813,13 @@ defmodule Domain.Auth.Adapters.Okta.Jobs.SyncDirectoryTest do end) end - for _n <- 1..10 do - {:ok, pid} = Task.Supervisor.start_link() - assert execute(%{task_supervisor: pid}) == :ok - end + {:ok, pid} = Task.Supervisor.start_link() + + provider + |> Ecto.Changeset.change(last_syncs_failed: 9) + |> Repo.update!() + + assert execute(%{task_supervisor: pid}) == :ok assert_email_sent(fn email -> assert email.subject == "Firezone Identity Provider Sync Error" diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index 8d3f6cb8d..61cc2821f 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -230,6 +230,14 @@ defmodule Domain.GatewaysTest do end end + describe "create_internet_group/1" do + test "creates a group on empty attrs", %{account: account} do + assert {:ok, group} = create_internet_group(account) + assert group.name == "Internet" + assert group.managed_by == :system + end + end + describe "change_group/1" do test "returns changeset with given changes" do group = Fixtures.Gateways.create_group() diff --git a/elixir/apps/domain/test/support/fixtures/gateways.ex b/elixir/apps/domain/test/support/fixtures/gateways.ex index 52bfc6eb2..34ffe03cc 100644 --- a/elixir/apps/domain/test/support/fixtures/gateways.ex +++ b/elixir/apps/domain/test/support/fixtures/gateways.ex @@ -4,7 +4,8 @@ defmodule Domain.Fixtures.Gateways do def group_attrs(attrs \\ %{}) do Enum.into(attrs, %{ - name: "group-#{unique_integer()}" + name: "group-#{unique_integer()}", + managed_by: :account }) end diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 6e76fcb76..e5bff09ed 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -1475,11 +1475,12 @@ defmodule Web.CoreComponents do """ attr :color, :string, default: "info" + attr :title, :string, default: nil attr :class, :string, default: nil def ping_icon(assigns) do ~H""" - + +
<.header> <:title> {render_slot(@title)} diff --git a/elixir/apps/web/lib/web/live/policies/new.ex b/elixir/apps/web/lib/web/live/policies/new.ex index 41b2aa349..373046cfe 100644 --- a/elixir/apps/web/lib/web/live/policies/new.ex +++ b/elixir/apps/web/lib/web/live/policies/new.ex @@ -110,17 +110,18 @@ defmodule Web.Policies.New do <% else %> {resource.name} + + 0} + class="text-neutral-500 inline-flex" + > + (<.resource_gateway_groups gateway_groups={resource.gateway_groups} />) + <% end %> (not connected to any Site) - 0} - class="text-neutral-500 inline-flex" - > - (<.resource_gateway_groups gateway_groups={resource.gateway_groups} />) - <:no_options :let={name}> diff --git a/elixir/apps/web/lib/web/live/resources/edit.ex b/elixir/apps/web/lib/web/live/resources/edit.ex index 426e5fdae..e6858ea3b 100644 --- a/elixir/apps/web/lib/web/live/resources/edit.ex +++ b/elixir/apps/web/lib/web/live/resources/edit.ex @@ -8,7 +8,8 @@ defmodule Web.Resources.Edit do Resources.fetch_resource_by_id(id, socket.assigns.subject, preload: :gateway_groups, filter: [ - deleted?: false + deleted?: false, + type: ["cidr", "dns", "ip"] ] ) do gateway_groups = Gateways.all_groups!(socket.assigns.subject) diff --git a/elixir/apps/web/lib/web/live/resources/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex index e01c0f45d..d98f590b8 100644 --- a/elixir/apps/web/lib/web/live/resources/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -7,15 +7,26 @@ defmodule Web.Resources.Index do :ok = Resources.subscribe_to_events_for_account(socket.assigns.account) end + internet_site = + case Domain.Gateways.fetch_internet_group(socket.assigns.account) do + {:ok, internet_site} -> internet_site + _ -> nil + end + socket = socket |> assign(page_title: "Resources") + |> assign(internet_site: internet_site) |> assign_live_table("resources", query_module: Resources.Resource.Query, sortable_fields: [ {:resources, :name}, {:resources, :address} ], + enforce_filters: [ + # The Internet Resource is shown in another section + {:type, {:not_in, ["internet"]}} + ], callback: &handle_resources_update!/2 ) @@ -54,8 +65,16 @@ defmodule Web.Resources.Index do Resources <:help> - Resources define the subnets, hosts, and applications for which you want to manage access. You can manage Resources per Site - in the <.link navigate={~p"/#{@account}/sites"} class={link_style()}>Sites section. +

+ Resources define the subnets, hosts, and applications for which you want to manage access. You can manage Resources per Site + in the <.link navigate={~p"/#{@account}/sites"} class={link_style()}>Sites section. +

+

+ The Internet Resource can now be managed in the + <.link navigate={~p"/#{@account}/sites/#{@internet_site}"} class={link_style()}> + Internet Site. + +

<:action> <.docs_action path="/deploy/resources" /> diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index 0417ba3b8..264e6ba6f 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -114,7 +114,7 @@ defmodule Web.Resources.Show do (replaced) - <:action :if={is_nil(@resource.deleted_at)}> + <:action :if={@resource.type != :internet && is_nil(@resource.deleted_at)}> <.edit_button navigate={~p"/#{@account}/resources/#{@resource.id}/edit?#{@params}"}> Edit Resource diff --git a/elixir/apps/web/lib/web/live/sign_up.ex b/elixir/apps/web/lib/web/live/sign_up.ex index dfbc94d9b..cc4406201 100644 --- a/elixir/apps/web/lib/web/live/sign_up.ex +++ b/elixir/apps/web/lib/web/live/sign_up.ex @@ -423,18 +423,24 @@ defmodule Web.SignUp do }) end ) - |> Ecto.Multi.run( - :internet_resource, - fn _repo, %{account: account} -> - Domain.Resources.create_internet_resource(account) - end - ) |> Ecto.Multi.run( :default_site, fn _repo, %{account: account} -> Domain.Gateways.create_group(account, %{name: "Default Site"}) end ) + |> Ecto.Multi.run( + :internet_site, + fn _repo, %{account: account} -> + Domain.Gateways.create_internet_group(account) + end + ) + |> Ecto.Multi.run( + :internet_resource, + fn _repo, %{account: account, internet_site: internet_site} -> + Domain.Resources.create_internet_resource(account, internet_site) + end + ) |> Ecto.Multi.run( :send_email, fn _repo, %{account: account, identity: identity} -> diff --git a/elixir/apps/web/lib/web/live/sites/gateways/index.ex b/elixir/apps/web/lib/web/live/sites/gateways/index.ex index ac197c7e4..c425f897b 100644 --- a/elixir/apps/web/lib/web/live/sites/gateways/index.ex +++ b/elixir/apps/web/lib/web/live/sites/gateways/index.ex @@ -65,7 +65,11 @@ defmodule Web.Sites.Gateways.Index do <:title> Site {@group.name} Gateways - <:help> + <:help :if={@group.managed_by == :system and @group.name == "Internet"}> + Gateways deployed to the Internet Site will be used for full-route tunneling + of traffic that doesn't match a more specific Resource. + + <:help :if={is_nil(@group.deleted_at) and @group.managed_by == :account}> Deploy gateways to terminate connections to your site's resources. All gateways deployed within a site must be able to reach all its resources. @@ -99,9 +103,16 @@ defmodule Web.Sites.Gateways.Index do
No gateways to display. - <.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}> - Deploy a gateway to connect resources. - + + <.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}> + Deploy a Gateway to the Internet Site. + + + + <.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}> + Deploy a Gateway to connect Resources. + +
diff --git a/elixir/apps/web/lib/web/live/sites/index.ex b/elixir/apps/web/lib/web/live/sites/index.ex index a6a1d39de..15b6d8a99 100644 --- a/elixir/apps/web/lib/web/live/sites/index.ex +++ b/elixir/apps/web/lib/web/live/sites/index.ex @@ -1,20 +1,53 @@ defmodule Web.Sites.Index do use Web, :live_view alias Domain.Gateways + require Logger def mount(_params, _session, socket) do if connected?(socket) do :ok = Gateways.subscribe_to_gateways_presence_in_account(socket.assigns.account) end + {:ok, managed_groups, _metadata} = + Gateways.list_groups(socket.assigns.subject, + preload: [ + gateways: [:online?] + ], + filter: [ + managed_by: "system" + ] + ) + + {internet_resource, existing_group_name} = + with {:ok, internet_resource} <- + Domain.Resources.fetch_internet_resource(socket.assigns.subject, + preload: [connections: :gateway_group] + ), + connection when not is_nil(connection) <- + Enum.find(internet_resource.connections, fn connection -> + connection.gateway_group.name != "Internet" && connection.managed_by != "system" + end) do + {internet_resource, connection.gateway_group.name} + else + _ -> {nil, nil} + end + + internet_gateway_group = Enum.find(managed_groups, fn group -> group.name == "Internet" end) + socket = socket |> assign(page_title: "Sites") + |> assign(internet_resource: internet_resource) + |> assign(existing_internet_resource_group_name: existing_group_name) + |> assign(internet_gateway_group: internet_gateway_group) |> assign_live_table("groups", query_module: Gateways.Group.Query, sortable_fields: [ {:groups, :name} ], + enforce_filters: [ + {:managed_by, "account"} + ], callback: &handle_groups_update!/2 ) @@ -170,6 +203,97 @@ defmodule Web.Sites.Index do + + <.section :if={@internet_gateway_group} id="internet-site-banner"> + <:title> +
+ Internet + + <% online? = Enum.any?(@internet_gateway_group.gateways, & &1.online?) %> + + <.ping_icon + :if={Domain.Accounts.internet_resource_enabled?(@account)} + color={if online?, do: "success", else: "danger"} + title={if online?, do: "Online", else: "Offline"} + /> + + <.link + :if={not Domain.Accounts.internet_resource_enabled?(@account)} + navigate={~p"/#{@account}/settings/billing"} + class="text-sm text-primary-500" + > + <.badge type="primary" title="Feature available on a higher pricing plan"> + <.icon name="hero-lock-closed" class="w-3.5 h-3.5 mr-1" /> UPGRADE TO UNLOCK + + +
+ + + <:action> + <.docs_action path="/deploy/resources" fragment="the-internet-resource" /> + + + <:action :if={Domain.Accounts.internet_resource_enabled?(@account)}> + <.edit_button navigate={~p"/#{@account}/sites/#{@internet_gateway_group}"}> + Manage Internet Site + + + + <:help> + Use the Internet Site to manage secure, private access to the public internet for your workforce. + + + <:content :if={ + Domain.Accounts.internet_resource_enabled?(@account) && + needs_internet_resource_migration?(@internet_resource, @internet_gateway_group) + }> +
+

+ ACTION REQUIRED: Please migrate your existing Internet Resource to this Site before March 15, 2025. +

+

+ <.website_link path="/blog/internet-resource-migration"> + Read more about why this is necessary. + +

+ <.button_with_confirmation + id="migrate_internet_resource" + style="warning" + confirm_style="warning" + icon="hero-exclamation-triangle-solid" + on_confirm="migrate_internet_resource" + > + <:dialog_title> + Confirm Internet Resource Migration from {@existing_internet_resource_group_name} + + <:dialog_content> +

+ <.icon name="hero-exclamation-triangle-solid" class="w-16 h-16 text-primary-500" /> +

+

+ Migrating the Internet Resource will permanently + move it from the {@existing_internet_resource_group_name} + Site to the Internet + Site. This cannot be reversed. +

+

+ Any Clients connected to this Resource will be immediately disconnected. +

+

+ To minimize downtime, it is recommended to deploy new Gateways in the Internet Site before completing the migration of the Internet Resource. +

+ + <:dialog_confirm_button> + Migrate Internet Resource + + <:dialog_cancel_button> + Cancel + + Migrate Internet Resource + +
+ + """ end @@ -177,9 +301,93 @@ defmodule Web.Sites.Index do %Phoenix.Socket.Broadcast{topic: "presences:account_gateways:" <> _account_id}, socket ) do - {:noreply, reload_live_table!(socket, "groups")} + {:ok, managed_groups, _metadata} = + Gateways.list_groups(socket.assigns.subject, + preload: [ + gateways: [:online?] + ], + filter: [ + managed_by: "system" + ] + ) + + internet_resource = + case Domain.Resources.fetch_internet_resource(socket.assigns.subject, preload: :connections) do + {:ok, internet_resource} -> internet_resource + _ -> nil + end + + internet_gateway_group = Enum.find(managed_groups, fn group -> group.name == "Internet" end) + + socket = + socket + |> assign(internet_resource: internet_resource) + |> assign(internet_gateway_group: internet_gateway_group) + |> reload_live_table!("groups") + + {:noreply, socket} end def handle_event(event, params, socket) when event in ["paginate", "order_by", "filter"], do: handle_live_table_event(event, params, socket) + + def handle_event("migrate_internet_resource", _, socket) do + internet_resource = socket.assigns.internet_resource + internet_gateway_group = socket.assigns.internet_gateway_group + + case migrate_internet_resource( + internet_resource, + internet_gateway_group, + socket.assigns.subject + ) do + {:ok, internet_resource} -> + socket = + socket + |> assign(internet_resource: internet_resource) + |> put_flash(:info, "Internet Resource migrated successfully.") + + {:noreply, socket} + + _ -> + {:noreply, socket |> put_flash(:error, "Failed to migrate Internet Resource.")} + end + end + + defp needs_internet_resource_migration?(nil, _), do: false + + defp needs_internet_resource_migration?(internet_resource, internet_gateway_group) do + # can only be in the internet site now + length(internet_resource.connections) > 1 || + Enum.all?(internet_resource.connections, fn connection -> + connection.gateway_group_id != internet_gateway_group.id + end) + end + + defp migrate_internet_resource(internet_resource, internet_gateway_group, subject) do + attrs = %{ + connections: %{ + internet_gateway_group.id => %{ + gateway_group_id: internet_gateway_group.id, + resource_id: internet_resource.id, + enabled: true + } + } + } + + Domain.Repo.transaction(fn -> + with {:ok, _count} <- Domain.Resources.delete_connections_for(internet_resource, subject), + {:updated, resource} <- + Domain.Resources.update_or_replace_resource(internet_resource, attrs, subject) do + resource + else + {:error, changeset} -> + Logger.error("Failed to migrate Internet Resource", + reason: inspect(changeset), + account: internet_resource.account_id + ) + + Domain.Repo.rollback(changeset) + end + end) + end end diff --git a/elixir/apps/web/lib/web/live/sites/show.ex b/elixir/apps/web/lib/web/live/sites/show.ex index eda7aca81..bda776fdc 100644 --- a/elixir/apps/web/lib/web/live/sites/show.ex +++ b/elixir/apps/web/lib/web/live/sites/show.ex @@ -1,6 +1,6 @@ defmodule Web.Sites.Show do use Web, :live_view - alias Domain.{Gateways, Resources, Tokens} + alias Domain.{Gateways, Resources, Policies, Flows, Tokens} def mount(%{"id" => id}, _session, socket) do with {:ok, group} <- @@ -20,6 +20,27 @@ defmodule Web.Sites.Show do page_title: "Site #{group.name}", group: group ) + + mount_page(socket, group) + else + {:error, _reason} -> raise Web.LiveErrors.NotFoundError + end + end + + defp mount_page(socket, %{managed_by: :system, name: "Internet"} = group) do + with {:ok, resource} <- + Resources.fetch_internet_resource(socket.assigns.subject, + preload: [ + :gateway_groups, + :created_by_actor, + created_by_identity: [:actor], + replaced_by_resource: [], + replaces_resource: [] + ] + ) do + socket = + socket + |> assign(resource: resource) |> assign_live_table("gateways", query_module: Gateways.Gateway.Query, enforce_filters: [ @@ -30,16 +51,23 @@ defmodule Web.Sites.Show do ], callback: &handle_gateways_update!/2 ) - |> assign_live_table("resources", - query_module: Resources.Resource.Query, + |> assign_live_table("flows", + query_module: Flows.Flow.Query, + sortable_fields: [], + callback: &handle_flows_update!/2 + ) + |> assign_live_table("policies", + query_module: Policies.Policy.Query, + hide_filters: [ + :actor_group_id, + :resource_name, + :group_or_resource_name + ], enforce_filters: [ - {:gateway_group_id, group.id} + {:resource_id, resource.id} ], - sortable_fields: [ - {:resources, :name}, - {:resources, :address} - ], - callback: &handle_resources_update!/2 + sortable_fields: [], + callback: &handle_policies_update!/2 ) {:ok, socket} @@ -48,6 +76,34 @@ defmodule Web.Sites.Show do end end + defp mount_page(socket, group) do + socket = + socket + |> assign_live_table("gateways", + query_module: Gateways.Gateway.Query, + enforce_filters: [ + {:gateway_group_id, group.id} + ], + sortable_fields: [ + {:gateways, :last_seen_at} + ], + callback: &handle_gateways_update!/2 + ) + |> assign_live_table("resources", + query_module: Resources.Resource.Query, + enforce_filters: [ + {:gateway_group_id, group.id} + ], + sortable_fields: [ + {:resources, :name}, + {:resources, :address} + ], + callback: &handle_resources_update!/2 + ) + + {:ok, socket} + end + def handle_params(params, uri, socket) do socket = handle_live_tables_params(socket, params, uri) {:noreply, socket} @@ -86,6 +142,36 @@ defmodule Web.Sites.Show do end end + def handle_policies_update!(socket, list_opts) do + list_opts = Keyword.put(list_opts, :preload, actor_group: [:provider], resource: []) + + with {:ok, policies, metadata} <- Policies.list_policies(socket.assigns.subject, list_opts) do + {:ok, + assign(socket, + policies: policies, + policies_metadata: metadata + )} + end + end + + def handle_flows_update!(socket, list_opts) do + list_opts = + Keyword.put(list_opts, :preload, + client: [:actor], + gateway: [:group], + policy: [:resource, :actor_group] + ) + + with {:ok, flows, metadata} <- + Flows.list_flows_for(socket.assigns.resource, socket.assigns.subject, list_opts) do + {:ok, + assign(socket, + flows: flows, + flows_metadata: metadata + )} + end + end + def render(assigns) do ~H""" <.breadcrumbs account={@account}> @@ -100,13 +186,18 @@ defmodule Web.Sites.Show do Site: {@group.name} (deleted) - <:action :if={is_nil(@group.deleted_at)}> + + <:action :if={is_nil(@group.deleted_at) and @group.managed_by == :account}> <.edit_button navigate={~p"/#{@account}/sites/#{@group}/edit"}> Edit Site - <:content> + <:help :if={@group.managed_by == :system and @group.name == "Internet"}> + Use this Site to manage secure, private access to the public internet for your workforce. + + + <:content :if={@group.managed_by != :system and @group.name != "Internet"}> <.vertical_table id="group"> <.vertical_table_row> <:label>Name @@ -158,7 +249,10 @@ defmodule Web.Sites.Show do Revoke All - <:help :if={is_nil(@group.deleted_at)}> + <:help :if={@group.managed_by == :system and @group.name == "Internet"}> + Gateways deployed to the Internet Site are used to tunnel all traffic that doesn't match any specific Resource. + + <:help :if={is_nil(@group.deleted_at) and @group.managed_by == :account}> Deploy gateways to terminate connections to your site's resources. All gateways deployed within a site must be able to reach all its resources. @@ -203,7 +297,15 @@ defmodule Web.Sites.Show do
No gateways to display. - + + <.link + class={[link_style()]} + navigate={~p"/#{@account}/sites/#{@group}/new_token"} + > + Deploy a Gateway to the Internet Site. + + + <.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"} @@ -219,7 +321,7 @@ defmodule Web.Sites.Show do - <.section> + <.section :if={@group.managed_by == :account}> <:title> Resources @@ -295,7 +397,68 @@ defmodule Web.Sites.Show do - <.danger_zone :if={is_nil(@group.deleted_at)}> + <.section :if={@group.managed_by == :system and @group.name == "Internet"}> + <:title> + Policies + + <:action> + <.add_button navigate={ + ~p"/#{@account}/policies/new?resource_id=#{@resource}&site_id=#{@group}" + }> + Add Policy + + + <:content> + <.live_table + id="policies" + rows={@policies} + row_id={&"policies-#{&1.id}"} + filters={@filters_by_table_id["policies"]} + filter={@filter_form_by_table_id["policies"]} + ordered_by={@order_by_table_id["policies"]} + metadata={@policies_metadata} + > + <:col :let={policy} label="id"> + <.link class={link_style()} navigate={~p"/#{@account}/policies/#{policy}"}> + {policy.id} + + + <:col :let={policy} label="group"> + <.group account={@account} group={policy.actor_group} /> + + <:col :let={policy} label="status"> + <%= if is_nil(policy.deleted_at) do %> + <%= if is_nil(policy.disabled_at) do %> + Active + <% else %> + Disabled + <% end %> + <% else %> + Deleted + <% end %> + + <:empty> +
+
+ <.icon + name="hero-exclamation-triangle-solid" + class="inline-block w-3.5 h-3.5 mr-1 text-red-500" + /> No policies to display. + <.link + class={[link_style()]} + navigate={~p"/#{@account}/policies/new?resource_id=#{@resource}&site_id=#{@group}"} + > + Add a policy + + to configure access to the internet. +
+
+ + + + + + <.danger_zone :if={is_nil(@group.deleted_at) and @group.managed_by == :account}> <:action> <.button_with_confirmation id="delete_site" diff --git a/elixir/apps/web/test/web/live/sign_up_test.exs b/elixir/apps/web/test/web/live/sign_up_test.exs index d2652a38a..7a2fa8687 100644 --- a/elixir/apps/web/test/web/live/sign_up_test.exs +++ b/elixir/apps/web/test/web/live/sign_up_test.exs @@ -79,9 +79,13 @@ defmodule Web.Live.SignUpTest do assert internet_resource.name == "Internet" assert internet_resource.type == :internet - gateway_group = Repo.one(Domain.Gateways.Group) - assert gateway_group.account_id == account.id - assert gateway_group.name == "Default Site" + default_gateway_group = Repo.get_by(Domain.Gateways.Group, name: "Default Site") + assert default_gateway_group.account_id == account.id + assert default_gateway_group.managed_by == :account + + internet_gateway_group = Repo.get_by(Domain.Gateways.Group, name: "Internet") + assert internet_gateway_group.account_id == account.id + assert internet_gateway_group.managed_by == :system end test "rate limits welcome emails", %{conn: conn} do diff --git a/elixir/apps/web/test/web/live/sites/index_test.exs b/elixir/apps/web/test/web/live/sites/index_test.exs index ac24b488c..973a61d93 100644 --- a/elixir/apps/web/test/web/live/sites/index_test.exs +++ b/elixir/apps/web/test/web/live/sites/index_test.exs @@ -112,4 +112,52 @@ defmodule Web.Live.Sites.IndexTest do assert row["online gateways"] =~ gateway.name end) end + + test "renders internet site with an option to upgrade on free plans", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, group} = Domain.Gateways.create_internet_group(account) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites") + + assert has_element?(lv, "#internet-site-banner") + assert lv |> element("#internet-site-banner") |> render() =~ "UPGRADE TO UNLOCK" + assert has_element?(lv, "#internet-site-banner a[href='/#{account.slug}/settings/billing']") + + refute has_element?(lv, "#internet-site-banner a[href='/#{account.slug}/sites/#{group.id}']") + end + + test "renders internet site with a status and manage button on paid plans", %{ + account: account, + identity: identity, + conn: conn + } do + account = Fixtures.Accounts.update_account(account, features: %{internet_resource: true}) + + {:ok, group} = Domain.Gateways.create_internet_group(account) + gateway = Fixtures.Gateways.create_gateway(account: account, group: group) + Domain.Config.put_env_override(:test_pid, self()) + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_account(account) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites") + + assert has_element?(lv, "#internet-site-banner") + assert lv |> element("#internet-site-banner") |> render() =~ "Offline" + refute has_element?(lv, "#internet-site-banner a[href='/#{account.slug}/settings/billing']") + + assert has_element?(lv, "#internet-site-banner a[href='/#{account.slug}/sites/#{group.id}']") + + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:account_gateways:" <> _} + assert_receive {:live_table_reloaded, "groups"}, 250 + assert lv |> element("#internet-site-banner") |> render() =~ "Online" + end end diff --git a/elixir/apps/web/test/web/live/sites/show_test.exs b/elixir/apps/web/test/web/live/sites/show_test.exs index f250d4068..bac82c2c8 100644 --- a/elixir/apps/web/test/web/live/sites/show_test.exs +++ b/elixir/apps/web/test/web/live/sites/show_test.exs @@ -71,260 +71,670 @@ defmodule Web.Live.Sites.ShowTest do assert breadcrumbs =~ group.name end - test "allows editing gateway groups", %{ - account: account, - group: group, - identity: identity, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + describe "for non-managed sites" do + test "allows editing gateway groups", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - assert lv - |> element("a", "Edit Site") - |> render_click() == - {:error, {:live_redirect, %{to: ~p"/#{account}/sites/#{group}/edit", kind: :push}}} - end + assert lv + |> element("a", "Edit Site") + |> render_click() == + {:error, {:live_redirect, %{to: ~p"/#{account}/sites/#{group}/edit", kind: :push}}} + end - test "renders group details", %{ - account: account, - actor: actor, - identity: identity, - group: group, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + test "renders group details", %{ + account: account, + actor: actor, + identity: identity, + group: group, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - table = - lv - |> element("#group") - |> render() - |> vertical_table_to_map() + table = + lv + |> element("#group") + |> render() + |> vertical_table_to_map() - assert table["name"] =~ group.name - assert table["created"] =~ actor.name - end + assert table["name"] =~ group.name + assert table["created"] =~ actor.name + end - test "renders group details when group created by API", %{ - account: account, - identity: identity, - conn: conn - } do - actor = Fixtures.Actors.create_actor(type: :api_client, account: account) - subject = Fixtures.Auth.create_subject(account: account, actor: actor) - group = Fixtures.Gateways.create_group(account: account, subject: subject) + test "renders group details when group created by API", %{ + account: account, + identity: identity, + conn: conn + } do + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + subject = Fixtures.Auth.create_subject(account: account, actor: actor) + group = Fixtures.Gateways.create_group(account: account, subject: subject) - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - table = - lv - |> element("#group") - |> render() - |> vertical_table_to_map() + table = + lv + |> element("#group") + |> render() + |> vertical_table_to_map() - assert table["name"] =~ group.name - assert table["created"] =~ actor.name - end + assert table["name"] =~ group.name + assert table["created"] =~ actor.name + end - test "renders online gateways table", %{ - account: account, - identity: identity, - group: group, - gateway: gateway, - conn: conn - } do - :ok = Domain.Gateways.connect_gateway(gateway) - Fixtures.Gateways.create_gateway(account: account, group: group) + test "renders online gateways table", %{ + account: account, + identity: identity, + group: group, + gateway: gateway, + conn: conn + } do + :ok = Domain.Gateways.connect_gateway(gateway) + Fixtures.Gateways.create_gateway(account: account, group: group) - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - rows = - lv - |> element("#gateways") - |> render() - |> table_to_map() + rows = + lv + |> element("#gateways") + |> render() + |> table_to_map() - assert length(rows) == 1 + assert length(rows) == 1 - rows - |> with_table_row("instance", gateway.name, fn row -> - assert gateway.last_seen_remote_ip - assert row["remote ip"] =~ to_string(gateway.last_seen_remote_ip) - assert row["version"] =~ gateway.last_seen_version - assert row["status"] =~ "Online" - end) - end - - test "updates online gateways table", %{ - account: account, - group: group, - gateway: gateway, - identity: identity, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") - - :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(group) - :ok = Domain.Gateways.connect_gateway(gateway) - assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} - - wait_for(fn -> - lv - |> element("#gateways") - |> render() - |> table_to_map() + rows |> with_table_row("instance", gateway.name, fn row -> + assert gateway.last_seen_remote_ip + assert row["remote ip"] =~ to_string(gateway.last_seen_remote_ip) + assert row["version"] =~ gateway.last_seen_version assert row["status"] =~ "Online" end) - end) - end + end - test "allows revoking all tokens", %{ - account: account, - group: group, - identity: identity, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") - - assert lv - |> element("button[type=submit]", "Revoke All") - |> render_click() =~ "1 token(s) were revoked." - - assert Repo.get_by(Domain.Tokens.Token, gateway_group_id: group.id).deleted_at - end - - test "renders resources table", %{ - account: account, - identity: identity, - group: group, - conn: conn - } do - resource = - Fixtures.Resources.create_resource( - account: account, - connections: [%{gateway_group_id: group.id}] - ) - - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") - - resource_rows = - lv - |> element("#resources") - |> render() - |> table_to_map() - - Enum.each(resource_rows, fn row -> - assert row["name"] =~ resource.name - assert row["address"] =~ resource.address - assert row["authorized groups"] == "None. Create a Policy to grant access." - end) - end - - test "renders authorized groups peek", %{ - account: account, - identity: identity, - group: group, - conn: conn - } do - resource = - Fixtures.Resources.create_resource( - account: account, - connections: [%{gateway_group_id: group.id}] - ) - - policies = - [ - Fixtures.Policies.create_policy( - account: account, - resource: resource - ), - Fixtures.Policies.create_policy( - account: account, - resource: resource - ), - Fixtures.Policies.create_policy( - account: account, - resource: resource - ) - ] - |> Repo.preload(:actor_group) - - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") - - resource_rows = - lv - |> element("#resources") - |> render() - |> table_to_map() - - Enum.each(resource_rows, fn row -> - for policy <- policies do - assert row["authorized groups"] =~ policy.actor_group.name - end - end) - - Fixtures.Policies.create_policy( + test "updates online gateways table", %{ account: account, - resource: resource - ) + group: group, + gateway: gateway, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(group) + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} + + wait_for(fn -> + lv + |> element("#gateways") + |> render() + |> table_to_map() + |> with_table_row("instance", gateway.name, fn row -> + assert row["status"] =~ "Online" + end) + end) + end + + test "allows revoking all tokens", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + assert lv + |> element("button[type=submit]", "Revoke All") + |> render_click() =~ "1 token(s) were revoked." + + assert Repo.get_by(Domain.Tokens.Token, gateway_group_id: group.id).deleted_at + end + + test "renders resources table", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: group.id}] + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + assert row["name"] =~ resource.name + assert row["address"] =~ resource.address + assert row["authorized groups"] == "None. Create a Policy to grant access." + end) + end + + test "renders authorized groups peek", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: group.id}] + ) + + policies = + [ + Fixtures.Policies.create_policy( + account: account, + resource: resource + ), + Fixtures.Policies.create_policy( + account: account, + resource: resource + ), + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + ] + |> Repo.preload(:actor_group) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + for policy <- policies do + assert row["authorized groups"] =~ policy.actor_group.name + end + end) + + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + assert row["authorized groups"] =~ "and 1 more" + end) + end + + test "allows deleting gateway groups", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - resource_rows = lv - |> element("#resources") - |> render() - |> table_to_map() + |> element("button[type=submit]", "Delete") + |> render_click() - Enum.each(resource_rows, fn row -> - assert row["authorized groups"] =~ "and 1 more" - end) + assert_redirected(lv, ~p"/#{account}/sites") + + assert Repo.get(Domain.Gateways.Group, group.id).deleted_at + end end - test "allows deleting gateway groups", %{ - account: account, - group: group, - identity: identity, - conn: conn - } do - {:ok, lv, _html} = - conn - |> authorize_conn(identity) - |> live(~p"/#{account}/sites/#{group}") + describe "for non-internet resources" do + test "allows editing gateway groups", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - lv - |> element("button[type=submit]", "Delete") - |> render_click() + assert lv + |> element("a", "Edit Site") + |> render_click() == + {:error, {:live_redirect, %{to: ~p"/#{account}/sites/#{group}/edit", kind: :push}}} + end - assert_redirected(lv, ~p"/#{account}/sites") + test "renders group details", %{ + account: account, + actor: actor, + identity: identity, + group: group, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") - assert Repo.get(Domain.Gateways.Group, group.id).deleted_at + table = + lv + |> element("#group") + |> render() + |> vertical_table_to_map() + + assert table["name"] =~ group.name + assert table["created"] =~ actor.name + end + + test "renders online gateways table", %{ + account: account, + identity: identity, + group: group, + gateway: gateway, + conn: conn + } do + :ok = Domain.Gateways.connect_gateway(gateway) + Fixtures.Gateways.create_gateway(account: account, group: group) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + rows = + lv + |> element("#gateways") + |> render() + |> table_to_map() + + assert length(rows) == 1 + + rows + |> with_table_row("instance", gateway.name, fn row -> + assert gateway.last_seen_remote_ip + assert row["remote ip"] =~ to_string(gateway.last_seen_remote_ip) + assert row["status"] =~ "Online" + end) + end + + test "updates online gateways table", %{ + account: account, + group: group, + gateway: gateway, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(group) + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} + + wait_for(fn -> + lv + |> element("#gateways") + |> render() + |> table_to_map() + |> with_table_row("instance", gateway.name, fn row -> + assert row["status"] =~ "Online" + end) + end) + end + + test "allows revoking all tokens", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + assert lv + |> element("button[type=submit]", "Revoke All") + |> render_click() =~ "1 token(s) were revoked." + + assert Repo.get_by(Domain.Tokens.Token, gateway_group_id: group.id).deleted_at + end + + test "renders resources table", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: group.id}] + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + assert row["name"] =~ resource.name + assert row["address"] =~ resource.address + assert row["authorized groups"] == "None. Create a Policy to grant access." + end) + end + + test "renders authorized groups peek", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + resource = + Fixtures.Resources.create_resource( + account: account, + connections: [%{gateway_group_id: group.id}] + ) + + policies = + [ + Fixtures.Policies.create_policy( + account: account, + resource: resource + ), + Fixtures.Policies.create_policy( + account: account, + resource: resource + ), + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + ] + |> Repo.preload(:actor_group) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + for policy <- policies do + assert row["authorized groups"] =~ policy.actor_group.name + end + end) + + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + resource_rows = + lv + |> element("#resources") + |> render() + |> table_to_map() + + Enum.each(resource_rows, fn row -> + assert row["authorized groups"] =~ "and 1 more" + end) + end + + test "allows deleting gateway groups", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + lv + |> element("button[type=submit]", "Delete") + |> render_click() + + assert_redirected(lv, ~p"/#{account}/sites") + + assert Repo.get(Domain.Gateways.Group, group.id).deleted_at + end + end + + describe "for internet sites" do + setup %{account: account} do + {:ok, group} = Domain.Gateways.create_internet_group(account) + gateway = Fixtures.Gateways.create_gateway(account: account, group: group) + gateway = Repo.preload(gateway, :group) + + {:ok, resource} = Domain.Resources.create_internet_resource(account, group) + + %{ + group: group, + gateway: gateway, + resource: resource + } + end + + test "does not allow editing", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + refute has_element?(lv, "a", "Edit Site") + end + + test "renders online gateways table", %{ + account: account, + identity: identity, + group: group, + gateway: gateway, + conn: conn + } do + :ok = Domain.Gateways.connect_gateway(gateway) + Fixtures.Gateways.create_gateway(account: account, group: group) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + rows = + lv + |> element("#gateways") + |> render() + |> table_to_map() + + assert length(rows) == 1 + + rows + |> with_table_row("instance", gateway.name, fn row -> + assert gateway.last_seen_remote_ip + assert row["remote ip"] =~ to_string(gateway.last_seen_remote_ip) + assert row["status"] =~ "Online" + end) + end + + test "updates online gateways table", %{ + account: account, + group: group, + gateway: gateway, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + :ok = Domain.Gateways.subscribe_to_gateways_presence_in_group(group) + :ok = Domain.Gateways.connect_gateway(gateway) + assert_receive %Phoenix.Socket.Broadcast{topic: "presences:group_gateways:" <> _} + + wait_for(fn -> + lv + |> element("#gateways") + |> render() + |> table_to_map() + |> with_table_row("instance", gateway.name, fn row -> + assert row["status"] =~ "Online" + end) + end) + end + + test "allows revoking all tokens", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + assert lv + |> element("button[type=submit]", "Revoke All") + |> render_click() =~ "1 token(s) were revoked." + + assert Repo.get_by(Domain.Tokens.Token, gateway_group_id: group.id).deleted_at + end + + test "does not render resources table", %{ + account: account, + identity: identity, + group: group, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + refute has_element?(lv, "#resources") + end + + test "renders policies table", %{ + account: account, + identity: identity, + group: group, + resource: resource, + conn: conn + } do + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + + Fixtures.Policies.create_policy( + account: account, + resource: resource + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + rows = + lv + |> element("#policies") + |> render() + |> table_to_map() + + assert Enum.all?(rows, fn row -> + assert row["group"] + assert row["id"] + assert row["status"] == "Active" + end) + end + + test "does not allow deleting the group", %{ + account: account, + group: group, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/sites/#{group}") + + refute has_element?(lv, "button[type=submit]", "Delete") + end end end