feat(portal): Internet Sites (#6905)

Related #6834

Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
This commit is contained in:
Andrew Dryga
2025-02-14 18:34:30 -06:00
committed by GitHub
parent 80aa9e76c1
commit bacb4596b7
35 changed files with 1373 additions and 311 deletions

View File

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

View File

@@ -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", _}} | _]}} ->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"""
<span class={["relative flex h-2.5 w-2.5", @class]}>
<span class={["relative flex h-2.5 w-2.5", @class]} title={@title}>
<span class={~w[
animate-ping absolute inline-flex
h-full w-full rounded-full opacity-50

View File

@@ -3,6 +3,7 @@ defmodule Web.PageComponents do
use Web, :verified_routes
import Web.CoreComponents
attr :id, :string, default: nil, doc: "The id of the section"
slot :title, required: true, doc: "The title of the section to be displayed"
slot :action, required: false, doc: "A slot for action to the right from title"
@@ -14,10 +15,13 @@ defmodule Web.PageComponents do
def section(assigns) do
~H"""
<div class={[
"mb-6 bg-white overflow-hidden shadow mx-5 rounded border px-6",
@content != [] && "pb-6"
]}>
<div
id={@id}
class={[
"mb-6 bg-white shadow mx-5 rounded border px-6",
@content != [] && "pb-6"
]}
>
<.header>
<:title>
{render_slot(@title)}

View File

@@ -110,17 +110,18 @@ defmodule Web.Policies.New do
</span>
<% else %>
{resource.name}
<span
:if={length(resource.gateway_groups) > 0}
class="text-neutral-500 inline-flex"
>
(<.resource_gateway_groups gateway_groups={resource.gateway_groups} />)
</span>
<% end %>
<span :if={resource.gateway_groups == []} class="text-red-800">
(not connected to any Site)
</span>
<span
:if={length(resource.gateway_groups) > 0}
class="text-neutral-500 inline-flex"
>
(<.resource_gateway_groups gateway_groups={resource.gateway_groups} />)
</span>
</:option>
<:no_options :let={name}>

View File

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

View File

@@ -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
</:title>
<: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</.link> section.
<p class="mb-2">
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</.link> section.
</p>
<p :if={Domain.Accounts.internet_resource_enabled?(@account) && @internet_site}>
The Internet Resource can now be managed in the
<.link navigate={~p"/#{@account}/sites/#{@internet_site}"} class={link_style()}>
Internet Site.
</.link>
</p>
</:help>
<:action>
<.docs_action path="/deploy/resources" />

View File

@@ -114,7 +114,7 @@ defmodule Web.Resources.Show do
(replaced)
</span>
</:title>
<: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
</.edit_button>

View File

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

View File

@@ -65,7 +65,11 @@ defmodule Web.Sites.Gateways.Index do
<:title>
Site <code>{@group.name}</code> Gateways
</:title>
<: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>
<: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
<div class="flex flex-col items-center justify-center text-center text-neutral-500 p-4">
<div class="pb-4">
No gateways to display.
<.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}>
Deploy a gateway to connect resources.
</.link>
<span :if={@group.managed_by == :system and @group.name == "Internet"}>
<.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}>
Deploy a Gateway to the Internet Site.
</.link>
</span>
<span :if={is_nil(@group.deleted_at) and @group.managed_by == :account}>
<.link class={[link_style()]} navigate={~p"/#{@account}/sites/#{@group}/new_token"}>
Deploy a Gateway to connect Resources.
</.link>
</span>
</div>
</div>
</:empty>

View File

@@ -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
</.live_table>
</:content>
</.section>
<.section :if={@internet_gateway_group} id="internet-site-banner">
<:title>
<div class="flex items-center space-x-2.5">
<span>Internet</span>
<% 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
</.badge>
</.link>
</div>
</:title>
<:action>
<.docs_action path="/deploy/resources" fragment="the-internet-resource" />
</:action>
<:action :if={Domain.Accounts.internet_resource_enabled?(@account)}>
<.edit_button navigate={~p"/#{@account}/sites/#{@internet_gateway_group}"}>
Manage Internet Site
</.edit_button>
</:action>
<:help>
Use the Internet Site to manage secure, private access to the public internet for your workforce.
</:help>
<:content :if={
Domain.Accounts.internet_resource_enabled?(@account) &&
needs_internet_resource_migration?(@internet_resource, @internet_gateway_group)
}>
<div class="px-1 text-neutral-500">
<p class="mb-2">
ACTION REQUIRED: Please migrate your existing Internet Resource to this Site before <strong>March 15, 2025</strong>.
</p>
<p class="mb-8 text-sm">
<.website_link path="/blog/internet-resource-migration">
Read more about why this is necessary.
</.website_link>
</p>
<.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_title>
<:dialog_content>
<p class="text-center my-8">
<.icon name="hero-exclamation-triangle-solid" class="w-16 h-16 text-primary-500" />
</p>
<p class="mb-2">
Migrating the Internet Resource will permanently
move it from the <strong>{@existing_internet_resource_group_name}</strong>
Site to the <strong>Internet</strong>
Site. This cannot be reversed.
</p>
<p class="mb-2">
Any Clients connected to this Resource will be immediately disconnected.
</p>
<p>
To minimize downtime, it is recommended to deploy new Gateways in the Internet Site before completing the migration of the Internet Resource.
</p>
</:dialog_content>
<:dialog_confirm_button>
Migrate Internet Resource
</:dialog_confirm_button>
<:dialog_cancel_button>
Cancel
</:dialog_cancel_button>
Migrate Internet Resource
</.button_with_confirmation>
</div>
</:content>
</.section>
"""
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

View File

@@ -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: <code>{@group.name}</code>
<span :if={not is_nil(@group.deleted_at)} class="text-red-600">(deleted)</span>
</:title>
<: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
</.edit_button>
</:action>
<: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.
</:help>
<:content :if={@group.managed_by != :system and @group.name != "Internet"}>
<.vertical_table id="group">
<.vertical_table_row>
<:label>Name</:label>
@@ -158,7 +249,10 @@ defmodule Web.Sites.Show do
Revoke All
</.button_with_confirmation>
</:action>
<: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>
<: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
<div class="flex flex-col items-center justify-center text-center text-neutral-500 p-4">
<div class="pb-4">
No gateways to display.
<span :if={is_nil(@group.deleted_at)}>
<span :if={@group.managed_by == :system and @group.name == "Internet"}>
<.link
class={[link_style()]}
navigate={~p"/#{@account}/sites/#{@group}/new_token"}
>
Deploy a Gateway to the Internet Site.
</.link>
</span>
<span :if={is_nil(@group.deleted_at) and @group.managed_by == :account}>
<.link
class={[link_style()]}
navigate={~p"/#{@account}/sites/#{@group}/new_token"}
@@ -219,7 +321,7 @@ defmodule Web.Sites.Show do
</:content>
</.section>
<.section>
<.section :if={@group.managed_by == :account}>
<:title>
Resources
</:title>
@@ -295,7 +397,68 @@ defmodule Web.Sites.Show do
</:content>
</.section>
<.danger_zone :if={is_nil(@group.deleted_at)}>
<.section :if={@group.managed_by == :system and @group.name == "Internet"}>
<:title>
Policies
</:title>
<:action>
<.add_button navigate={
~p"/#{@account}/policies/new?resource_id=#{@resource}&site_id=#{@group}"
}>
Add Policy
</.add_button>
</:action>
<: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}
</.link>
</:col>
<:col :let={policy} label="group">
<.group account={@account} group={policy.actor_group} />
</:col>
<: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 %>
</:col>
<:empty>
<div class="flex justify-center text-center text-neutral-500 p-4">
<div class="pb-4 w-auto">
<.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
</.link>
to configure access to the internet.
</div>
</div>
</:empty>
</.live_table>
</:content>
</.section>
<.danger_zone :if={is_nil(@group.deleted_at) and @group.managed_by == :account}>
<:action>
<.button_with_confirmation
id="delete_site"

View File

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

View File

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

View File

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