mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Actor groups and group sync helpers (#1727)
This commit is contained in:
@@ -1,7 +1,19 @@
|
||||
defmodule Domain.Accounts do
|
||||
alias Domain.{Repo, Validator}
|
||||
alias Domain.Auth
|
||||
alias Domain.Accounts.Account
|
||||
alias Domain.Accounts.{Authorizer, Account}
|
||||
|
||||
def fetch_account_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_accounts_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Account.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_account_by_id(id) do
|
||||
if Validator.valid_uuid?(id) do
|
||||
|
||||
19
elixir/apps/domain/lib/domain/accounts/authorizer.ex
Normal file
19
elixir/apps/domain/lib/domain/accounts/authorizer.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Accounts.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Accounts.Account
|
||||
|
||||
def view_accounts_permission, do: build(Account, :view)
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def list_permissions_for_role(_) do
|
||||
[view_accounts_permission()]
|
||||
end
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def for_subject(queryable, %Subject{} = subject) do
|
||||
cond do
|
||||
has_permission?(subject, view_accounts_permission()) ->
|
||||
Account.Query.by_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,110 @@
|
||||
defmodule Domain.Actors do
|
||||
alias Domain.{Repo, Auth, Validator}
|
||||
alias Domain.Actors.{Authorizer, Actor}
|
||||
alias Domain.Actors.{Authorizer, Actor, Group}
|
||||
require Ecto.Query
|
||||
|
||||
def fetch_group_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Group.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def list_groups(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
Group.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def new_group(attrs \\ %{}) do
|
||||
change_group(%Group{}, attrs)
|
||||
end
|
||||
|
||||
def upsert_provider_groups(%Auth.Provider{} = provider, attrs_by_provider_identifier) do
|
||||
attrs_by_provider_identifier
|
||||
|> Enum.reduce(Ecto.Multi.new(), fn {provider_identifier, attrs}, multi ->
|
||||
Ecto.Multi.insert(
|
||||
multi,
|
||||
{:group, provider_identifier},
|
||||
Group.Changeset.create_changeset(provider, provider_identifier, attrs),
|
||||
conflict_target: Group.Changeset.upsert_conflict_target(),
|
||||
on_conflict: Group.Changeset.upsert_on_conflict(),
|
||||
returning: true
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|
||||
# Ecto.Multi.new()
|
||||
# |> Ecto.Multi.insert(:actor, Actor.Changeset.create_changeset(provider, attrs))
|
||||
# |> Ecto.Multi.run(:identity, fn _repo, %{actor: actor} ->
|
||||
# Auth.create_identity(actor, provider, provider_identifier)
|
||||
# end)
|
||||
# |> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{group: group}} ->
|
||||
{:ok, group}
|
||||
|
||||
{:error, _step, changeset, _effects_so_far} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def group_synced?(%Group{provider_id: nil}), do: false
|
||||
def group_synced?(%Group{}), do: true
|
||||
|
||||
def create_group(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
subject.account
|
||||
|> Group.Changeset.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
def change_group(group, attrs \\ %{})
|
||||
|
||||
def change_group(%Group{provider_id: nil} = group, attrs) do
|
||||
group
|
||||
|> Repo.preload(:memberships)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
end
|
||||
|
||||
def change_group(%Group{}, _attrs) do
|
||||
raise ArgumentError, "can't change synced groups"
|
||||
end
|
||||
|
||||
def update_group(%Group{provider_id: nil} = group, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
group
|
||||
|> Repo.preload(:memberships)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def update_group(%Group{}, _attrs, %Auth.Subject{}) do
|
||||
{:error, :synced_group}
|
||||
end
|
||||
|
||||
def delete_group(%Group{provider_id: nil} = group, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
Group.Query.by_id(group.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Group.Query.by_account_id(subject.account.id)
|
||||
|> Repo.fetch_and_update(with: &Group.Changeset.delete_changeset/1)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_group(%Group{}, %Auth.Subject{}) do
|
||||
{:error, :synced_group}
|
||||
end
|
||||
|
||||
def fetch_count_by_type(type, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
Actor.Query.by_type(type)
|
||||
@@ -11,6 +113,22 @@ defmodule Domain.Actors do
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_groups_count_grouped_by_provider_id(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
{:ok, groups} =
|
||||
Group.Query.group_by_provider_id()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
|
||||
groups =
|
||||
Enum.reduce(groups, %{}, fn %{provider_id: id, count: count}, acc ->
|
||||
Map.put(acc, id, count)
|
||||
end)
|
||||
|
||||
{:ok, groups}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_actor_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
@@ -59,7 +177,7 @@ defmodule Domain.Actors do
|
||||
) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()),
|
||||
:ok <- Auth.ensure_has_access_to(subject, provider),
|
||||
changeset = Actor.Changeset.create_changeset(provider, attrs),
|
||||
changeset = Actor.Changeset.create_changeset(provider.account_id, attrs),
|
||||
{:ok, data} <- Ecto.Changeset.apply_action(changeset, :validate) do
|
||||
granted_permissions = Auth.fetch_type_permissions!(data.type)
|
||||
|
||||
@@ -77,7 +195,7 @@ defmodule Domain.Actors do
|
||||
|
||||
def create_actor(%Auth.Provider{} = provider, provider_identifier, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:actor, Actor.Changeset.create_changeset(provider, attrs))
|
||||
|> Ecto.Multi.insert(:actor, Actor.Changeset.create_changeset(provider.account_id, attrs))
|
||||
|> Ecto.Multi.run(:identity, fn _repo, %{actor: actor} ->
|
||||
Auth.create_identity(actor, provider, provider_identifier)
|
||||
end)
|
||||
|
||||
@@ -8,9 +8,11 @@ defmodule Domain.Actors.Actor do
|
||||
|
||||
has_many :identities, Domain.Auth.Identity
|
||||
|
||||
# belongs_to :group, Domain.Actors.Group
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
has_many :memberships, Domain.Actors.Membership, on_replace: :delete
|
||||
has_many :groups, through: [:memberships, :group]
|
||||
|
||||
field :disabled_at, :utc_datetime_usec
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
defmodule Domain.Actors.Actor.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
alias Domain.Actors
|
||||
|
||||
def create_changeset(%Auth.Provider{} = provider, attrs) do
|
||||
def create_changeset(account_id, attrs) do
|
||||
%Actors.Actor{}
|
||||
|> cast(attrs, ~w[type name]a)
|
||||
|> validate_required(~w[type name]a)
|
||||
|> put_change(:account_id, provider.account_id)
|
||||
|> put_change(:account_id, account_id)
|
||||
end
|
||||
|
||||
def set_actor_type(actor, type) when type in [:account_user, :account_admin_user] do
|
||||
def set_actor_type(actor, type) do
|
||||
actor
|
||||
|> change()
|
||||
|> put_change(:type, type)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
defmodule Domain.Actors.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Actors.Actor
|
||||
alias Domain.Actors.{Actor, Group}
|
||||
|
||||
def manage_actors_permission, do: build(Actor, :manage)
|
||||
def edit_own_profile_permission, do: build(Actor, :edit_own_profile)
|
||||
@@ -27,7 +27,17 @@ defmodule Domain.Actors.Authorizer do
|
||||
def for_subject(queryable, %Subject{} = subject) do
|
||||
cond do
|
||||
has_permission?(subject, manage_actors_permission()) ->
|
||||
Actor.Query.by_account_id(queryable, subject.account.id)
|
||||
by_account_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
|
||||
defp by_account_id(queryable, account_id) do
|
||||
cond do
|
||||
Ecto.Query.has_named_binding?(queryable, :groups) ->
|
||||
Group.Query.by_account_id(queryable, account_id)
|
||||
|
||||
Ecto.Query.has_named_binding?(queryable, :actors) ->
|
||||
Actor.Query.by_account_id(queryable, account_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
19
elixir/apps/domain/lib/domain/actors/group.ex
Normal file
19
elixir/apps/domain/lib/domain/actors/group.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Actors.Group do
|
||||
use Domain, :schema
|
||||
|
||||
schema "actor_groups" do
|
||||
field :name, :string
|
||||
|
||||
# Those fields will be set for groups we synced from IdP's
|
||||
belongs_to :provider, Domain.Auth.Provider
|
||||
field :provider_identifier, :string
|
||||
|
||||
has_many :memberships, Domain.Actors.Membership, on_replace: :delete
|
||||
has_many :actors, through: [:memberships, :actor]
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
58
elixir/apps/domain/lib/domain/actors/group/changeset.ex
Normal file
58
elixir/apps/domain/lib/domain/actors/group/changeset.ex
Normal file
@@ -0,0 +1,58 @@
|
||||
defmodule Domain.Actors.Group.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.{Auth, Accounts}
|
||||
alias Domain.Actors
|
||||
|
||||
@fields ~w[name]a
|
||||
|
||||
def upsert_conflict_target do
|
||||
{:unsafe_fragment,
|
||||
"(account_id, provider_id, provider_identifier) " <>
|
||||
"WHERE deleted_at IS NULL AND provider_id IS NOT NULL AND provider_identifier IS NOT NULL"}
|
||||
end
|
||||
|
||||
# We do not update the `name` field because we allow to manually override it in the UI
|
||||
# for usability reasons when the provider uses group names that can make people confused
|
||||
def upsert_on_conflict, do: {:replace, (@fields -- ~w[name]a) ++ ~w[updated_at]a}
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
%Actors.Group{account_id: account.id}
|
||||
|> changeset(attrs)
|
||||
|> validate_length(:name, min: 1, max: 64)
|
||||
|> cast_assoc(:memberships,
|
||||
with: &Actors.Membership.Changeset.group_changeset(account.id, &1, &2)
|
||||
)
|
||||
end
|
||||
|
||||
def create_changeset(%Auth.Provider{} = provider, provider_identifier, attrs) do
|
||||
%Actors.Group{}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:provider_id, provider.id)
|
||||
|> put_change(:provider_identifier, provider_identifier)
|
||||
|> cast_assoc(:memberships,
|
||||
with: &Actors.Membership.Changeset.group_changeset(provider.account_id, &1, &2)
|
||||
)
|
||||
end
|
||||
|
||||
def update_changeset(%Actors.Group{} = group, attrs) do
|
||||
changeset(group, attrs)
|
||||
|> validate_length(:name, min: 1, max: 64)
|
||||
|> cast_assoc(:memberships,
|
||||
with: &Actors.Membership.Changeset.group_changeset(group.account_id, &1, &2)
|
||||
)
|
||||
end
|
||||
|
||||
defp changeset(group, attrs) do
|
||||
group
|
||||
|> cast(attrs, @fields)
|
||||
|> validate_required(@fields)
|
||||
|> trim_change(:name)
|
||||
|> unique_constraint(:name, name: :actor_groups_account_id_name_index)
|
||||
end
|
||||
|
||||
def delete_changeset(%Actors.Group{} = group) do
|
||||
group
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
end
|
||||
38
elixir/apps/domain/lib/domain/actors/group/query.ex
Normal file
38
elixir/apps/domain/lib/domain/actors/group/query.ex
Normal file
@@ -0,0 +1,38 @@
|
||||
defmodule Domain.Actors.Group.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(groups in Domain.Actors.Group, as: :groups)
|
||||
|> where([groups: groups], is_nil(groups.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [groups: groups], groups.id == ^id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [groups: groups], groups.account_id == ^account_id)
|
||||
end
|
||||
|
||||
def by_provider_id(queryable \\ all(), provider_id) do
|
||||
where(queryable, [groups: groups], groups.provider_id == ^provider_id)
|
||||
end
|
||||
|
||||
def by_provider_identifier(queryable \\ all(), provider_identifier) do
|
||||
where(queryable, [groups: groups], groups.provider_identifier == ^provider_identifier)
|
||||
end
|
||||
|
||||
def group_by_provider_id(queryable \\ all()) do
|
||||
queryable
|
||||
|> group_by([groups: groups], groups.provider_id)
|
||||
|> where([groups: groups], not is_nil(groups.provider_id))
|
||||
|> select([groups: groups], %{
|
||||
provider_id: groups.provider_id,
|
||||
count: count(groups.id)
|
||||
})
|
||||
end
|
||||
|
||||
def lock(queryable \\ all()) do
|
||||
lock(queryable, "FOR UPDATE")
|
||||
end
|
||||
end
|
||||
11
elixir/apps/domain/lib/domain/actors/membership.ex
Normal file
11
elixir/apps/domain/lib/domain/actors/membership.ex
Normal file
@@ -0,0 +1,11 @@
|
||||
defmodule Domain.Actors.Membership do
|
||||
use Domain, :schema
|
||||
|
||||
@primary_key false
|
||||
schema "actor_group_memberships" do
|
||||
belongs_to :group, Domain.Actors.Group, primary_key: true
|
||||
belongs_to :actor, Domain.Actors.Actor, primary_key: true
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
end
|
||||
end
|
||||
18
elixir/apps/domain/lib/domain/actors/membership/changeset.ex
Normal file
18
elixir/apps/domain/lib/domain/actors/membership/changeset.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule Domain.Actors.Membership.Changeset do
|
||||
use Domain, :changeset
|
||||
|
||||
def group_changeset(account_id, connection, attrs) do
|
||||
connection
|
||||
|> cast(attrs, ~w[actor_id]a)
|
||||
|> validate_required(~w[actor_id]a)
|
||||
|> changeset(account_id)
|
||||
end
|
||||
|
||||
defp changeset(changeset, account_id) do
|
||||
changeset
|
||||
|> assoc_constraint(:actor)
|
||||
|> assoc_constraint(:group)
|
||||
|> assoc_constraint(:account)
|
||||
|> put_change(:account_id, account_id)
|
||||
end
|
||||
end
|
||||
19
elixir/apps/domain/lib/domain/actors/membership/query.ex
Normal file
19
elixir/apps/domain/lib/domain/actors/membership/query.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Actors.Membership.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(memberships in Domain.Actors.Membership, as: :memberships)
|
||||
end
|
||||
|
||||
def by_actor_id(queryable \\ all(), actor_id) do
|
||||
where(queryable, [memberships: memberships], memberships.actor_id == ^actor_id)
|
||||
end
|
||||
|
||||
def by_group_id(queryable \\ all(), group_id) do
|
||||
where(queryable, [memberships: memberships], memberships.group_id == ^group_id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [memberships: memberships], memberships.account_id == ^account_id)
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,62 @@ defmodule Domain.Auth do
|
||||
|
||||
# Providers
|
||||
|
||||
def list_provider_adapters do
|
||||
Adapters.list_adapters()
|
||||
end
|
||||
|
||||
def fetch_provider_by_id(id, %Subject{} = subject, opts \\ []) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Provider.Query.by_id(id)
|
||||
|> Authorizer.for_subject(Provider, subject)
|
||||
|> Repo.fetch()
|
||||
|> case do
|
||||
{:ok, provider} ->
|
||||
{:ok, Repo.preload(provider, preload)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_provider_by_id(id) do
|
||||
if Validator.valid_uuid?(id) do
|
||||
Provider.Query.by_id(id)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_active_provider_by_id(id, %Subject{} = subject, opts \\ []) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Provider.Query.by_id(id)
|
||||
|> Provider.Query.not_disabled()
|
||||
|> Authorizer.for_subject(Provider, subject)
|
||||
|> Repo.fetch()
|
||||
|> case do
|
||||
{:ok, provider} ->
|
||||
{:ok, Repo.preload(provider, preload)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_active_provider_by_id(id) do
|
||||
if Validator.valid_uuid?(id) do
|
||||
Provider.Query.by_id(id)
|
||||
@@ -34,23 +90,62 @@ defmodule Domain.Auth do
|
||||
end
|
||||
end
|
||||
|
||||
def list_providers_for_account(%Accounts.Account{} = account, %Subject{} = subject) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
|
||||
:ok <- Accounts.ensure_has_access_to(subject, account) do
|
||||
Provider.Query.by_account_id(account.id)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def list_active_providers_for_account(%Accounts.Account{} = account) do
|
||||
Provider.Query.by_account_id(account.id)
|
||||
|> Provider.Query.not_disabled()
|
||||
|> Repo.list()
|
||||
end
|
||||
|
||||
def new_provider(%Accounts.Account{} = account, attrs \\ %{}) do
|
||||
Provider.Changeset.create_changeset(account, attrs)
|
||||
|> Adapters.provider_changeset()
|
||||
end
|
||||
|
||||
def create_provider(%Accounts.Account{} = account, attrs, %Subject{} = subject) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()),
|
||||
:ok <- Accounts.ensure_has_access_to(subject, account) do
|
||||
create_provider(account, attrs)
|
||||
:ok <- Accounts.ensure_has_access_to(subject, account),
|
||||
changeset =
|
||||
Provider.Changeset.create_changeset(account, attrs, subject)
|
||||
|> Adapters.provider_changeset(),
|
||||
{:ok, provider} <- Repo.insert(changeset) do
|
||||
Adapters.ensure_provisioned(provider)
|
||||
end
|
||||
end
|
||||
|
||||
def create_provider(%Accounts.Account{} = account, attrs) do
|
||||
Provider.Changeset.create_changeset(account, attrs)
|
||||
|> Adapters.ensure_provisioned_for_account(account)
|
||||
|> Repo.insert()
|
||||
changeset =
|
||||
Provider.Changeset.create_changeset(account, attrs)
|
||||
|> Adapters.provider_changeset()
|
||||
|
||||
with {:ok, provider} <- Repo.insert(changeset) do
|
||||
Adapters.ensure_provisioned(provider)
|
||||
end
|
||||
end
|
||||
|
||||
def change_provider(%Provider{} = provider, attrs \\ %{}) do
|
||||
Provider.Changeset.update_changeset(provider, attrs)
|
||||
|> Adapters.provider_changeset()
|
||||
end
|
||||
|
||||
def update_provider(%Provider{} = provider, attrs, %Subject{} = subject) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do
|
||||
Provider.Query.by_id(provider.id)
|
||||
|> Authorizer.for_subject(Provider, subject)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn provider ->
|
||||
Provider.Changeset.update_changeset(provider, attrs)
|
||||
|> Adapters.provider_changeset()
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def disable_provider(%Provider{} = provider, %Subject{} = subject) do
|
||||
@@ -86,12 +181,18 @@ defmodule Domain.Auth do
|
||||
if other_active_providers_exist?(provider) do
|
||||
provider
|
||||
|> Provider.Changeset.delete_provider()
|
||||
|> Adapters.ensure_deprovisioned()
|
||||
else
|
||||
:cant_delete_the_last_provider
|
||||
end
|
||||
end
|
||||
)
|
||||
|> case do
|
||||
{:ok, provider} ->
|
||||
Adapters.ensure_deprovisioned(provider)
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,6 +204,10 @@ defmodule Domain.Auth do
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
def fetch_provider_capabilities!(%Provider{} = provider) do
|
||||
Adapters.fetch_capabilities!(provider)
|
||||
end
|
||||
|
||||
# Identities
|
||||
|
||||
def fetch_identity_by_id(id) do
|
||||
@@ -115,13 +220,53 @@ defmodule Domain.Auth do
|
||||
|> Repo.fetch!()
|
||||
end
|
||||
|
||||
def fetch_identities_count_grouped_by_provider_id(%Subject{} = subject) do
|
||||
with :ok <- ensure_has_permissions(subject, Authorizer.manage_identities_permission()) do
|
||||
{:ok, identities} =
|
||||
Identity.Query.group_by_provider_id()
|
||||
|> Authorizer.for_subject(Identity, subject)
|
||||
|> Repo.list()
|
||||
|
||||
identities =
|
||||
Enum.reduce(identities, %{}, fn %{provider_id: id, count: count}, acc ->
|
||||
Map.put(acc, id, count)
|
||||
end)
|
||||
|
||||
{:ok, identities}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_identity(
|
||||
%Actors.Actor{} = actor,
|
||||
%Provider{} = provider,
|
||||
provider_identifier,
|
||||
provider_attrs \\ %{}
|
||||
) do
|
||||
Identity.Changeset.create_identity(actor, provider, provider_identifier)
|
||||
|> Adapters.identity_changeset(provider, provider_attrs)
|
||||
|> Repo.insert(
|
||||
conflict_target:
|
||||
{:unsafe_fragment,
|
||||
~s/(account_id, provider_id, provider_identifier) WHERE deleted_at IS NULL/},
|
||||
on_conflict:
|
||||
{:replace,
|
||||
[
|
||||
:provider_state,
|
||||
:last_seen_user_agent,
|
||||
:last_seen_remote_ip,
|
||||
:last_seen_at
|
||||
]},
|
||||
returning: true
|
||||
)
|
||||
end
|
||||
|
||||
def create_identity(
|
||||
%Actors.Actor{} = actor,
|
||||
%Provider{} = provider,
|
||||
provider_identifier,
|
||||
provider_attrs \\ %{}
|
||||
) do
|
||||
Identity.Changeset.create(actor, provider, provider_identifier)
|
||||
Identity.Changeset.create_identity(actor, provider, provider_identifier)
|
||||
|> Adapters.identity_changeset(provider, provider_attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
@@ -149,7 +294,12 @@ defmodule Domain.Auth do
|
||||
|> Repo.fetch()
|
||||
end)
|
||||
|> Ecto.Multi.insert(:new_identity, fn %{identity: identity} ->
|
||||
Identity.Changeset.create(identity.actor, identity.provider, provider_identifier)
|
||||
Identity.Changeset.create_identity(
|
||||
identity.actor,
|
||||
identity.provider,
|
||||
provider_identifier,
|
||||
subject
|
||||
)
|
||||
|> Adapters.identity_changeset(identity.provider, provider_attrs)
|
||||
end)
|
||||
|> Ecto.Multi.update(:deleted_identity, fn %{identity: identity} ->
|
||||
@@ -199,8 +349,7 @@ defmodule Domain.Auth do
|
||||
end
|
||||
|
||||
def sign_in(%Provider{} = provider, payload, user_agent, remote_ip) do
|
||||
with {:ok, identity, expires_at} <-
|
||||
Adapters.verify_identity(provider, payload) do
|
||||
with {:ok, identity, expires_at} <- Adapters.verify_and_update_identity(provider, payload) do
|
||||
{:ok, build_subject(identity, expires_at, user_agent, remote_ip)}
|
||||
else
|
||||
{:error, :not_found} -> {:error, :unauthorized}
|
||||
@@ -232,7 +381,7 @@ defmodule Domain.Auth do
|
||||
when is_binary(user_agent) and is_tuple(remote_ip) do
|
||||
identity =
|
||||
identity
|
||||
|> Identity.Changeset.sign_in(user_agent, remote_ip)
|
||||
|> Identity.Changeset.sign_in_identity(user_agent, remote_ip)
|
||||
|> Repo.update!()
|
||||
|
||||
identity_with_preloads = Repo.preload(identity, [:account, :actor])
|
||||
@@ -261,6 +410,7 @@ defmodule Domain.Auth do
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
# TODO: we don't want client token to be invalid if you reconnect client from a different ip,
|
||||
# for the clients that move between cellular towers
|
||||
# TODO: we want to show all sessions in a UI so persist them to DB
|
||||
payload = session_token_payload(subject)
|
||||
max_age = DateTime.diff(subject.expires_at, DateTime.utc_now(), :second)
|
||||
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
defmodule Domain.Auth.Adapter do
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.{Provider, Identity}
|
||||
|
||||
@typedoc """
|
||||
This type defines which kind of provisioners are enabled for IdP adapter.
|
||||
|
||||
The `:custom` is a special key which means that the IdP adapter implements
|
||||
its own provisioning logic (eg. API integration), so it should be rendered
|
||||
in the UI on pre-provider basis.
|
||||
"""
|
||||
@type provisioner :: :manual | :just_in_time | :custom
|
||||
|
||||
@typedoc """
|
||||
Setting parent adapter is important because it will allow to reuse auth flows
|
||||
on the front-end for multiple IdP adapters.
|
||||
"""
|
||||
@type parent_adapter :: nil | atom()
|
||||
|
||||
@type capability ::
|
||||
{:parent_adapter, parent_adapter()}
|
||||
| {:provisioners, [provisioner()]}
|
||||
| {:default_provisioner, provisioner()}
|
||||
|
||||
@doc """
|
||||
This callback returns list of provider capabilities for a better UI rendering.
|
||||
"""
|
||||
@callback capabilities() :: [capability()]
|
||||
|
||||
@doc """
|
||||
Applies provider-specific validations for the Identity changeset before it's created.
|
||||
"""
|
||||
@callback identity_changeset(%Provider{}, %Ecto.Changeset{data: %Identity{}}) ::
|
||||
%Ecto.Changeset{data: %Identity{}}
|
||||
|
||||
@doc """
|
||||
Adds adapter-specific validations to the provider changeset.
|
||||
"""
|
||||
@callback provider_changeset(%Ecto.Changeset{data: %Provider{}}) ::
|
||||
%Ecto.Changeset{data: %Provider{}}
|
||||
|
||||
@doc """
|
||||
A callback which is triggered when the provider is first created.
|
||||
|
||||
It should impotently ensure that the provider is provisioned on the third-party end,
|
||||
eg. it can use a REST API to configure SCIM webhook and token.
|
||||
"""
|
||||
@callback ensure_provisioned_for_account(
|
||||
%Ecto.Changeset{data: %Provider{}},
|
||||
%Accounts.Account{}
|
||||
) :: %Ecto.Changeset{data: %Provider{}}
|
||||
@callback ensure_provisioned(%Provider{}) ::
|
||||
{:ok, %Provider{}} | {:error, %Ecto.Changeset{data: %Provider{}}}
|
||||
|
||||
@doc """
|
||||
A callback which is triggered when the provider is deleted.
|
||||
@@ -25,8 +53,8 @@ defmodule Domain.Auth.Adapter do
|
||||
It should impotently ensure that the provider is deprovisioned on the third-party end,
|
||||
eg. it can use a REST API to remove SCIM webhook and token.
|
||||
"""
|
||||
@callback ensure_deprovisioned(%Ecto.Changeset{data: %Provider{}}) ::
|
||||
%Ecto.Changeset{data: %Provider{}}
|
||||
@callback ensure_deprovisioned(%Provider{}) ::
|
||||
{:ok, %Provider{}} | {:error, %Ecto.Changeset{data: %Provider{}}}
|
||||
|
||||
defmodule Local do
|
||||
@doc """
|
||||
@@ -46,7 +74,7 @@ defmodule Domain.Auth.Adapter do
|
||||
@doc """
|
||||
Used for adapters that are not secret-based, eg. OpenID Connect.
|
||||
"""
|
||||
@callback verify_identity(%Provider{}, payload :: term()) ::
|
||||
@callback verify_and_update_identity(%Provider{}, payload :: term()) ::
|
||||
{:ok, %Identity{}, expires_at :: %DateTime{} | nil}
|
||||
| {:error, :invalid_secret}
|
||||
| {:error, :expired_secret}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
defmodule Domain.Auth.Adapters do
|
||||
use Supervisor
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.{Provider, Identity}
|
||||
|
||||
@adapters %{
|
||||
email: Domain.Auth.Adapters.Email,
|
||||
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
|
||||
google_workspace: Domain.Auth.Adapters.GoogleWorkspace,
|
||||
userpass: Domain.Auth.Adapters.UserPass,
|
||||
token: Domain.Auth.Adapters.Token
|
||||
}
|
||||
@@ -21,32 +21,63 @@ defmodule Domain.Auth.Adapters do
|
||||
Supervisor.init(@adapter_modules, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def list_adapters do
|
||||
enabled_adapters = Domain.Config.compile_config!(:auth_provider_adapters)
|
||||
enabled_idp_adapters = enabled_adapters -- ~w[token email userpass]a
|
||||
{:ok, Map.take(@adapters, enabled_idp_adapters)}
|
||||
end
|
||||
|
||||
def fetch_capabilities!(%Provider{} = provider) do
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
adapter.capabilities()
|
||||
end
|
||||
|
||||
def fetch_capabilities!(adapter) when is_atom(adapter) do
|
||||
fetch_adapter!(adapter).capabilities()
|
||||
end
|
||||
|
||||
def identity_changeset(%Ecto.Changeset{} = changeset, %Provider{} = provider, provider_attrs) do
|
||||
adapter = fetch_adapter!(provider)
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
changeset = Ecto.Changeset.put_change(changeset, :provider_virtual_state, provider_attrs)
|
||||
%Ecto.Changeset{} = adapter.identity_changeset(provider, changeset)
|
||||
end
|
||||
|
||||
def ensure_provisioned_for_account(
|
||||
%Ecto.Changeset{changes: %{adapter: adapter}} = changeset,
|
||||
%Accounts.Account{} = account
|
||||
)
|
||||
def provider_changeset(%Ecto.Changeset{changes: %{adapter: adapter}} = changeset)
|
||||
when adapter in @adapter_names do
|
||||
adapter = Map.fetch!(@adapters, adapter)
|
||||
%Ecto.Changeset{} = adapter.ensure_provisioned_for_account(changeset, account)
|
||||
%Ecto.Changeset{} = adapter.provider_changeset(changeset)
|
||||
end
|
||||
|
||||
def ensure_provisioned_for_account(%Ecto.Changeset{} = changeset, %Accounts.Account{}) do
|
||||
def provider_changeset(%Ecto.Changeset{data: %{adapter: adapter}} = changeset)
|
||||
when adapter in @adapter_names do
|
||||
adapter = Map.fetch!(@adapters, adapter)
|
||||
%Ecto.Changeset{} = adapter.provider_changeset(changeset)
|
||||
end
|
||||
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
end
|
||||
|
||||
def ensure_deprovisioned(%Ecto.Changeset{data: %Provider{} = provider} = changeset) do
|
||||
adapter = fetch_adapter!(provider)
|
||||
%Ecto.Changeset{} = adapter.ensure_deprovisioned(changeset)
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
|
||||
case adapter.ensure_provisioned(provider) do
|
||||
{:ok, provider} -> {:ok, provider}
|
||||
{:error, %Ecto.Changeset{} = changeset} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
|
||||
case adapter.ensure_deprovisioned(provider) do
|
||||
{:ok, provider} -> {:ok, provider}
|
||||
{:error, %Ecto.Changeset{} = changeset} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def verify_secret(%Provider{} = provider, %Identity{} = identity, secret) do
|
||||
adapter = fetch_adapter!(provider)
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
|
||||
case adapter.verify_secret(identity, secret) do
|
||||
{:ok, %Identity{} = identity, expires_at} -> {:ok, identity, expires_at}
|
||||
@@ -56,10 +87,10 @@ defmodule Domain.Auth.Adapters do
|
||||
end
|
||||
end
|
||||
|
||||
def verify_identity(%Provider{} = provider, payload) do
|
||||
adapter = fetch_adapter!(provider)
|
||||
def verify_and_update_identity(%Provider{} = provider, payload) do
|
||||
adapter = fetch_provider_adapter!(provider)
|
||||
|
||||
case adapter.verify_identity(provider, payload) do
|
||||
case adapter.verify_and_update_identity(provider, payload) do
|
||||
{:ok, %Identity{} = identity, expires_at} -> {:ok, identity, expires_at}
|
||||
{:error, :not_found} -> {:error, :not_found}
|
||||
{:error, :invalid} -> {:error, :invalid}
|
||||
@@ -68,7 +99,11 @@ defmodule Domain.Auth.Adapters do
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_adapter!(provider) do
|
||||
defp fetch_provider_adapter!(%Provider{} = provider) do
|
||||
Map.fetch!(@adapters, provider.adapter)
|
||||
end
|
||||
|
||||
defp fetch_adapter!(adapter_name) do
|
||||
Map.fetch!(@adapters, adapter_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
defmodule Domain.Auth.Adapters.Email do
|
||||
use Supervisor
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.{Identity, Provider, Adapter}
|
||||
|
||||
@behaviour Adapter
|
||||
@@ -20,6 +19,15 @@ defmodule Domain.Auth.Adapters.Email do
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:manual],
|
||||
default_provisioner: :manual,
|
||||
parent_adapter: nil
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def identity_changeset(%Provider{} = provider, %Ecto.Changeset{} = changeset) do
|
||||
{state, virtual_state} = identity_create_state(provider)
|
||||
@@ -32,10 +40,11 @@ defmodule Domain.Auth.Adapters.Email do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_provisioned_for_account(%Ecto.Changeset{} = changeset, %Accounts.Account{} = account) do
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
%{
|
||||
outbound_email_adapter: outbound_email_adapter
|
||||
} = Domain.Config.fetch_resolved_configs!(account.id, [:outbound_email_adapter])
|
||||
} =
|
||||
Domain.Config.fetch_resolved_configs!(changeset.data.account_id, [:outbound_email_adapter])
|
||||
|
||||
if is_nil(outbound_email_adapter) do
|
||||
Ecto.Changeset.add_error(changeset, :adapter, "email adapter is not configured")
|
||||
@@ -45,8 +54,13 @@ defmodule Domain.Auth.Adapters.Email do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
defp identity_create_state(%Provider{} = _provider) do
|
||||
@@ -63,7 +77,6 @@ defmodule Domain.Auth.Adapters.Email do
|
||||
}
|
||||
end
|
||||
|
||||
# XXX: Send actual email here once web has templates
|
||||
def request_sign_in_token(%Identity{} = identity) do
|
||||
identity = Repo.preload(identity, :provider)
|
||||
{state, virtual_state} = identity_create_state(identity.provider)
|
||||
@@ -101,7 +114,7 @@ defmodule Domain.Auth.Adapters.Email do
|
||||
:invalid_secret
|
||||
|
||||
true ->
|
||||
Identity.Changeset.update_provider_state(identity, %{}, %{})
|
||||
Identity.Changeset.update_identity_provider_state(identity, %{}, %{})
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspace do
|
||||
use Supervisor
|
||||
alias Domain.Actors
|
||||
alias Domain.Auth.{Provider, Adapter}
|
||||
alias Domain.Auth.Adapters.OpenIDConnect
|
||||
alias Domain.Auth.Adapters.GoogleWorkspace
|
||||
require Logger
|
||||
|
||||
@behaviour Adapter
|
||||
@behaviour Adapter.IdP
|
||||
|
||||
def start_link(_init_arg) do
|
||||
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
GoogleWorkspace.APIClient,
|
||||
{Domain.Jobs, GoogleWorkspace.Jobs}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:just_in_time, :custom],
|
||||
default_provisioner: :custom,
|
||||
parent_adapter: :openid_connect
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Domain.Validator.trim_change(:provider_identifier)
|
||||
|> Domain.Validator.copy_change(:provider_virtual_state, :provider_state)
|
||||
|> Ecto.Changeset.put_change(:provider_virtual_state, %{})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Domain.Changeset.cast_polymorphic_embed(:adapter_config,
|
||||
required: true,
|
||||
with: fn current_attrs, attrs ->
|
||||
Ecto.embedded_load(GoogleWorkspace.Settings, current_attrs, :json)
|
||||
|> OpenIDConnect.Settings.Changeset.changeset(attrs)
|
||||
end
|
||||
)
|
||||
|
||||
# TODO: validate received scopes and show an error if there are missing ones
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def verify_and_update_identity(%Provider{} = provider, payload) do
|
||||
OpenIDConnect.verify_and_update_identity(provider, payload)
|
||||
end
|
||||
|
||||
def verify_and_upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, payload) do
|
||||
OpenIDConnect.verify_and_upsert_identity(actor, provider, payload)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
|
||||
use Supervisor
|
||||
|
||||
@pool_name __MODULE__.Finch
|
||||
|
||||
def start_link(_init_arg) do
|
||||
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
{Finch,
|
||||
name: @pool_name,
|
||||
pools: %{
|
||||
default: pool_opts()
|
||||
}}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
defp pool_opts do
|
||||
transport_opts =
|
||||
Domain.Config.fetch_env!(:domain, __MODULE__)
|
||||
|> Keyword.fetch!(:finch_transport_opts)
|
||||
|
||||
[conn_opts: [transport_opts: transport_opts]]
|
||||
end
|
||||
|
||||
def list_users(api_token) do
|
||||
endpoint =
|
||||
Domain.Config.fetch_env!(:domain, __MODULE__)
|
||||
|> Keyword.fetch!(:endpoint)
|
||||
|
||||
uri =
|
||||
URI.parse("#{endpoint}/admin/directory/v1/users")
|
||||
|> URI.append_query(
|
||||
URI.encode_query(%{
|
||||
"customer" => "my_customer",
|
||||
"showDeleted" => false,
|
||||
"query" => "isSuspended=false isArchived=false",
|
||||
"fields" =>
|
||||
Enum.join(
|
||||
~w[
|
||||
users/id
|
||||
users/primaryEmail
|
||||
users/name/fullName
|
||||
users/orgUnitPath
|
||||
users/creationTime
|
||||
users/isEnforcedIn2Sv
|
||||
users/isEnrolledIn2Sv
|
||||
],
|
||||
","
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
list_all(uri, api_token, "users")
|
||||
end
|
||||
|
||||
def list_groups(api_token) do
|
||||
endpoint =
|
||||
Domain.Config.fetch_env!(:domain, __MODULE__)
|
||||
|> Keyword.fetch!(:endpoint)
|
||||
|
||||
uri =
|
||||
URI.parse("#{endpoint}/admin/directory/v1/groups")
|
||||
|> URI.append_query(
|
||||
URI.encode_query(%{
|
||||
"customer" => "my_customer"
|
||||
})
|
||||
)
|
||||
|
||||
list_all(uri, api_token, "groups")
|
||||
end
|
||||
|
||||
# Note: this functions does not return root (`/`) org unit
|
||||
def list_organization_units(api_token) do
|
||||
endpoint =
|
||||
Domain.Config.fetch_env!(:domain, __MODULE__)
|
||||
|> Keyword.fetch!(:endpoint)
|
||||
|
||||
uri = URI.parse("#{endpoint}/admin/directory/v1/customer/my_customer/orgunits")
|
||||
list_all(uri, api_token, "organizationUnits")
|
||||
end
|
||||
|
||||
def list_group_members(api_token, group_id) do
|
||||
endpoint =
|
||||
Domain.Config.fetch_env!(:domain, __MODULE__)
|
||||
|> Keyword.fetch!(:endpoint)
|
||||
|
||||
uri = URI.parse("#{endpoint}/admin/directory/v1/groups/#{group_id}/members")
|
||||
|
||||
with {:ok, members} <- list_all(uri, api_token, "members") do
|
||||
members =
|
||||
Enum.filter(members, fn member ->
|
||||
member["type"] == "USER" and member["status"] == "ACTIVE"
|
||||
end)
|
||||
|
||||
{:ok, members}
|
||||
end
|
||||
end
|
||||
|
||||
defp list_all(uri, api_token, key, acc \\ []) do
|
||||
case list(uri, api_token, key) do
|
||||
{:ok, list, nil} ->
|
||||
{:ok, List.flatten(Enum.reverse([list | acc]))}
|
||||
|
||||
{:ok, list, next_page_token} ->
|
||||
uri
|
||||
|> URI.append_query(URI.encode_query(%{"pageToken" => next_page_token}))
|
||||
|> list_all(api_token, key, [list | acc])
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp list(uri, api_token, key) do
|
||||
request = Finch.build(:get, uri, [{"Authorization", "Bearer #{api_token}"}])
|
||||
|
||||
with {:ok, %Finch.Response{body: response, status: status}} when status in 200..299 <-
|
||||
Finch.request(request, @pool_name),
|
||||
{:ok, json_response} <- Jason.decode(response),
|
||||
{:ok, list} <- Map.fetch(json_response, key) do
|
||||
{:ok, list, json_response["nextPageToken"]}
|
||||
else
|
||||
{:ok, %Finch.Response{status: status}} when status in 500..599 -> {:error, :retry_later}
|
||||
{:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}}
|
||||
:error -> {:ok, [], nil}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
|
||||
use Domain.Jobs.Recurrent, otp_app: :domain
|
||||
require Logger
|
||||
|
||||
every minutes(5), :refresh_access_tokens do
|
||||
Logger.debug("Refreshing tokens")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspace.Settings do
|
||||
use Domain, :schema
|
||||
|
||||
@scope ~w[
|
||||
openid email profile
|
||||
https://www.googleapis.com/auth/admin.directory.orgunit.readonly
|
||||
https://www.googleapis.com/auth/admin.directory.group.readonly
|
||||
https://www.googleapis.com/auth/admin.directory.user.readonly
|
||||
]
|
||||
|
||||
@discovery_document_uri "https://accounts.google.com/.well-known/openid-configuration"
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :scope, :string, default: Enum.join(@scope, " ")
|
||||
field :response_type, :string, default: "code"
|
||||
field :client_id, :string
|
||||
field :client_secret, :string
|
||||
field :discovery_document_uri, :string, default: @discovery_document_uri
|
||||
end
|
||||
end
|
||||
|
||||
# field :provisioners, Ecto.Enum, values: [:manual, :just_in_time, :custom]
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
use Supervisor
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts
|
||||
alias Domain.Actors
|
||||
alias Domain.Auth.{Identity, Provider, Adapter}
|
||||
alias Domain.Auth.Adapters.OpenIDConnect.{Settings, State, PKCE}
|
||||
require Logger
|
||||
@@ -20,6 +20,15 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:just_in_time],
|
||||
default_provisioner: :just_in_time,
|
||||
parent_adapter: :openid_connect
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
@@ -29,8 +38,9 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_provisioned_for_account(%Ecto.Changeset{} = changeset, %Accounts.Account{}) do
|
||||
Domain.Changeset.cast_polymorphic_embed(changeset, :adapter_config,
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Domain.Changeset.cast_polymorphic_embed(:adapter_config,
|
||||
required: true,
|
||||
with: fn current_attrs, attrs ->
|
||||
Ecto.embedded_load(Settings, current_attrs, :json)
|
||||
@@ -40,8 +50,13 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
def authorization_uri(%Provider{} = provider, redirect_uri) when is_binary(redirect_uri) do
|
||||
@@ -71,8 +86,9 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def verify_identity(%Provider{} = provider, {redirect_uri, code_verifier, code}) do
|
||||
sync_identity(provider, %{
|
||||
def verify_and_update_identity(%Provider{} = provider, {redirect_uri, code_verifier, code}) do
|
||||
provider
|
||||
|> sync_identity(%{
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirect_uri,
|
||||
code: code,
|
||||
@@ -87,6 +103,24 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
end
|
||||
|
||||
def verify_and_upsert_identity(
|
||||
%Actors.Actor{} = actor,
|
||||
%Provider{} = provider,
|
||||
{redirect_uri, code_verifier, code}
|
||||
) do
|
||||
token_params = %{
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirect_uri,
|
||||
code: code,
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
with {:ok, provider_identifier, identity_state} <-
|
||||
fetch_identity_state(provider, token_params) do
|
||||
Domain.Auth.upsert_identity(actor, provider, provider_identifier, identity_state)
|
||||
end
|
||||
end
|
||||
|
||||
def refresh_token(%Identity{} = identity) do
|
||||
identity = Repo.preload(identity, :provider)
|
||||
|
||||
@@ -96,13 +130,17 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
})
|
||||
end
|
||||
|
||||
defp sync_identity(%Provider{} = provider, token_params) do
|
||||
defp fetch_identity_state(%Provider{} = provider, token_params) do
|
||||
config = config_for_provider(provider)
|
||||
|
||||
with {:ok, tokens} <- OpenIDConnect.fetch_tokens(config, token_params),
|
||||
{:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]),
|
||||
{:ok, userinfo} <- OpenIDConnect.fetch_userinfo(config, tokens["access_token"]) do
|
||||
# TODO: sync groups
|
||||
# TODO: refresh the access token so it doesn't expire
|
||||
# TODO: first admin user token that configured provider should used for periodic syncs
|
||||
# TODO: active status for relays, gateways in list functions
|
||||
# TODO: JIT provisioning
|
||||
expires_at =
|
||||
cond do
|
||||
not is_nil(tokens["expires_in"]) ->
|
||||
@@ -117,26 +155,14 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
|
||||
provider_identifier = claims["sub"]
|
||||
|
||||
Identity.Query.by_provider_id(provider.id)
|
||||
|> Identity.Query.by_provider_identifier(provider_identifier)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn identity ->
|
||||
Identity.Changeset.update_provider_state(
|
||||
identity,
|
||||
%{
|
||||
access_token: tokens["access_token"],
|
||||
refresh_token: tokens["refresh_token"],
|
||||
expires_at: expires_at,
|
||||
userinfo: userinfo,
|
||||
claims: claims
|
||||
}
|
||||
)
|
||||
end
|
||||
)
|
||||
|> case do
|
||||
{:ok, identity} -> {:ok, identity, expires_at}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
{:ok, provider_identifier,
|
||||
%{
|
||||
access_token: tokens["access_token"],
|
||||
refresh_token: tokens["refresh_token"],
|
||||
expires_at: expires_at,
|
||||
userinfo: userinfo,
|
||||
claims: claims
|
||||
}}
|
||||
else
|
||||
{:error, {:invalid_jwt, "invalid exp claim: token has expired"}} ->
|
||||
{:error, :expired_token}
|
||||
@@ -154,6 +180,23 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_identity(%Provider{} = provider, token_params) do
|
||||
with {:ok, provider_identifier, identity_state} <-
|
||||
fetch_identity_state(provider, token_params) do
|
||||
Identity.Query.by_provider_id(provider.id)
|
||||
|> Identity.Query.by_provider_identifier(provider_identifier)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn identity ->
|
||||
Identity.Changeset.update_identity_provider_state(identity, identity_state)
|
||||
end
|
||||
)
|
||||
|> case do
|
||||
{:ok, identity} -> {:ok, identity, identity_state.expires_at}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp config_for_provider(%Provider{} = provider) do
|
||||
Ecto.embedded_load(Settings, provider.adapter_config, :json)
|
||||
|> Map.from_struct()
|
||||
|
||||
@@ -22,12 +22,19 @@ defmodule Domain.Auth.Adapters.OpenIDConnect.Settings.Changeset do
|
||||
|
||||
def validate_discovery_document_uri(changeset) do
|
||||
validate_change(changeset, :discovery_document_uri, fn :discovery_document_uri, value ->
|
||||
case OpenIDConnect.Document.fetch_document(value) do
|
||||
{:ok, _update_result} ->
|
||||
[]
|
||||
with {:ok, %URI{scheme: scheme, host: host}} when not is_nil(scheme) and not is_nil(host) <-
|
||||
URI.new(value),
|
||||
{:ok, _update_result} <- OpenIDConnect.Document.fetch_document(value) do
|
||||
[]
|
||||
else
|
||||
{:ok, _uri} ->
|
||||
[{:discovery_document_uri, "is not a valid URL"}]
|
||||
|
||||
{:error, reason} ->
|
||||
[{:discovery_document_uri, "is invalid. Reason: #{inspect(reason)}"}]
|
||||
{:error, {404, _body}} ->
|
||||
[{:discovery_document_uri, "does not exist"}]
|
||||
|
||||
{:error, {status, _body}} ->
|
||||
[{:discovery_document_uri, "is invalid, got #{status} HTTP response"}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
defmodule Domain.Auth.Adapters.Token do
|
||||
@moduledoc """
|
||||
This is not recommended to use in production,
|
||||
it's only for development, testing, and small home labs.
|
||||
This provider is used to authenticate service account using API keys.
|
||||
"""
|
||||
use Supervisor
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.{Identity, Provider, Adapter}
|
||||
alias Domain.Auth.Adapters.Token.State
|
||||
|
||||
@@ -23,6 +21,15 @@ defmodule Domain.Auth.Adapters.Token do
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:manual],
|
||||
default_provisioner: :manual,
|
||||
group: nil
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
@@ -66,13 +73,18 @@ defmodule Domain.Auth.Adapters.Token do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_provisioned_for_account(%Ecto.Changeset{} = changeset, %Accounts.Account{}) do
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -5,7 +5,6 @@ defmodule Domain.Auth.Adapters.UserPass do
|
||||
"""
|
||||
use Supervisor
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.{Identity, Provider, Adapter}
|
||||
alias Domain.Auth.Adapters.UserPass.Password
|
||||
|
||||
@@ -23,6 +22,15 @@ defmodule Domain.Auth.Adapters.UserPass do
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:manual],
|
||||
default_provisioner: :manual,
|
||||
parent_adapter: nil
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
@@ -57,13 +65,18 @@ defmodule Domain.Auth.Adapters.UserPass do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_provisioned_for_account(%Ecto.Changeset{} = changeset, %Accounts.Account{}) do
|
||||
def provider_changeset(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
def ensure_provisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_deprovisioned(%Provider{} = provider) do
|
||||
{:ok, provider}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -15,6 +15,9 @@ defmodule Domain.Auth.Identity do
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[system provider identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
defmodule Domain.Auth.Identity.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Actors
|
||||
alias Domain.Auth.{Identity, Provider}
|
||||
alias Domain.Auth.{Subject, Identity, Provider}
|
||||
|
||||
def create(
|
||||
def create_identity(actor, provider, provider_identifier, %Subject{} = subject) do
|
||||
actor
|
||||
|> create_identity(provider, provider_identifier)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def create_identity(
|
||||
%Actors.Actor{account_id: account_id} = actor,
|
||||
%Provider{account_id: account_id} = provider,
|
||||
provider_identifier
|
||||
@@ -15,19 +22,20 @@ defmodule Domain.Auth.Identity.Changeset do
|
||||
|> put_change(:account_id, account_id)
|
||||
|> put_change(:provider_identifier, provider_identifier)
|
||||
|> unique_constraint(:provider_identifier,
|
||||
name: :auth_identities_provider_id_provider_identifier_index
|
||||
name: :auth_identities_account_id_provider_id_provider_identifier_idx
|
||||
)
|
||||
|> validate_required(:provider_identifier)
|
||||
|> put_change(:created_by, :system)
|
||||
end
|
||||
|
||||
def update_provider_state(identity_or_changeset, %{} = state, virtual_state \\ %{}) do
|
||||
def update_identity_provider_state(identity_or_changeset, %{} = state, virtual_state \\ %{}) do
|
||||
identity_or_changeset
|
||||
|> change()
|
||||
|> put_change(:provider_state, state)
|
||||
|> put_change(:provider_virtual_state, virtual_state)
|
||||
end
|
||||
|
||||
def sign_in(identity_or_changeset, user_agent, remote_ip) do
|
||||
def sign_in_identity(identity_or_changeset, user_agent, remote_ip) do
|
||||
identity_or_changeset
|
||||
|> change()
|
||||
|> put_change(:last_seen_user_agent, user_agent)
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Domain.Auth.Identity.Mutator do
|
||||
require Ecto.Query
|
||||
|
||||
def update_provider_state(identity, state_changeset, virtual_state \\ %{}) do
|
||||
Identity.Changeset.update_provider_state(identity, state_changeset, virtual_state)
|
||||
Identity.Changeset.update_identity_provider_state(identity, state_changeset, virtual_state)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,6 +63,15 @@ defmodule Domain.Auth.Identity.Query do
|
||||
lock(queryable, "FOR UPDATE")
|
||||
end
|
||||
|
||||
def group_by_provider_id(queryable \\ all()) do
|
||||
queryable
|
||||
|> group_by([identities: identities], identities.provider_id)
|
||||
|> select([identities: identities], %{
|
||||
provider_id: identities.provider_id,
|
||||
count: count(identities.id)
|
||||
})
|
||||
end
|
||||
|
||||
def with_preloaded_assoc(queryable \\ all(), type \\ :left, assoc) do
|
||||
queryable
|
||||
|> with_assoc(type, assoc)
|
||||
|
||||
@@ -4,11 +4,19 @@ defmodule Domain.Auth.Provider do
|
||||
schema "auth_providers" do
|
||||
field :name, :string
|
||||
|
||||
field :adapter, Ecto.Enum, values: ~w[email openid_connect userpass token]a
|
||||
field :adapter, Ecto.Enum, values: ~w[email openid_connect google_workspace userpass token]a
|
||||
field :provisioner, Ecto.Enum, values: ~w[manual just_in_time custom]a
|
||||
field :adapter_config, :map
|
||||
field :adapter_state, :map
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
has_many :groups, Domain.Actors.Group
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[system identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :last_synced_at, :utc_datetime_usec
|
||||
field :disabled_at, :utc_datetime_usec
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
defmodule Domain.Auth.Provider.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.Auth.Provider
|
||||
alias Domain.Auth.{Subject, Provider}
|
||||
|
||||
@fields ~w[name adapter adapter_config]a
|
||||
@required_fields @fields
|
||||
@create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a
|
||||
@update_fields ~w[name adapter_config adapter_state provisioner disabled_at deleted_at]a
|
||||
@required_fields ~w[name adapter adapter_config provisioner]a
|
||||
|
||||
def create_changeset(account, attrs, %Subject{} = subject) do
|
||||
account
|
||||
|> create_changeset(attrs)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
%Provider{}
|
||||
|> cast(attrs, @fields)
|
||||
|> cast(attrs, @create_fields)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> changeset()
|
||||
|> put_change(:created_by, :system)
|
||||
end
|
||||
|
||||
def update_changeset(%Provider{} = provider, attrs) do
|
||||
provider
|
||||
|> cast(attrs, @update_fields)
|
||||
|> changeset()
|
||||
end
|
||||
|
||||
defp changeset(changeset) do
|
||||
changeset
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_required(@required_fields)
|
||||
|> unique_constraint(:adapter,
|
||||
|> validate_required(:adapter)
|
||||
|> unique_constraint(:base,
|
||||
name: :auth_providers_account_id_adapter_index,
|
||||
message: "this provider is already enabled"
|
||||
)
|
||||
|> unique_constraint(:adapter,
|
||||
|> unique_constraint(:base,
|
||||
name: :auth_providers_account_id_oidc_adapter_index,
|
||||
message: "this provider is already connected"
|
||||
)
|
||||
|> validate_provisioner()
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
|
||||
defp validate_provisioner(changeset) do
|
||||
with false <- has_errors?(changeset, :adapter),
|
||||
{_data_or_changes, adapter} <- fetch_field(changeset, :adapter) do
|
||||
capabilities = Domain.Auth.Adapters.fetch_capabilities!(adapter)
|
||||
provisioners = Keyword.fetch!(capabilities, :provisioners)
|
||||
default_provisioner = Keyword.fetch!(capabilities, :default_provisioner)
|
||||
|
||||
changeset
|
||||
|> validate_inclusion(:provisioner, provisioners)
|
||||
|> put_default_value(:provisioner, default_provisioner)
|
||||
else
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
def disable_provider(%Provider{} = provider) do
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule Domain.Auth.Roles do
|
||||
[
|
||||
Domain.Auth.Authorizer,
|
||||
Domain.Config.Authorizer,
|
||||
Domain.Accounts.Authorizer,
|
||||
Domain.Devices.Authorizer,
|
||||
Domain.Gateways.Authorizer,
|
||||
Domain.Relays.Authorizer,
|
||||
|
||||
@@ -117,6 +117,7 @@ defmodule Domain.Config do
|
||||
|
||||
if Mix.env() != :test do
|
||||
defdelegate fetch_env!(app, key), to: Application
|
||||
defdelegate get_env(app, key, default \\ nil), to: Application
|
||||
else
|
||||
def put_env_override(app \\ :domain, key, value) do
|
||||
Process.put(pdict_key_function(app, key), value)
|
||||
@@ -151,6 +152,20 @@ defmodule Domain.Config do
|
||||
end
|
||||
end
|
||||
|
||||
def get_env(app, key, default \\ nil) do
|
||||
application_env = Application.get_env(app, key, default)
|
||||
|
||||
pdict_key_function(app, key)
|
||||
|> Domain.Config.Resolver.fetch_process_env()
|
||||
|> case do
|
||||
{:ok, override} ->
|
||||
override
|
||||
|
||||
:error ->
|
||||
application_env
|
||||
end
|
||||
end
|
||||
|
||||
defp pdict_key_function(app, key), do: {app, key}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -444,13 +444,13 @@ defmodule Domain.Config.Definitions do
|
||||
"""
|
||||
defconfig(
|
||||
:auth_provider_adapters,
|
||||
{
|
||||
:array,
|
||||
",",
|
||||
{:parameterized, Ecto.Enum,
|
||||
Ecto.Enum.init(values: ~w[email openid_connect userpass token]a)}
|
||||
},
|
||||
default: ~w[email openid_connect token]a
|
||||
{:array, ",", {:parameterized, Ecto.Enum, Ecto.Enum.init(values: ~w[
|
||||
email
|
||||
openid_connect google_workspace
|
||||
userpass
|
||||
token
|
||||
]a)}},
|
||||
default: ~w[email openid_connect google_workspace token]a
|
||||
)
|
||||
|
||||
##############################################
|
||||
|
||||
@@ -6,7 +6,8 @@ defmodule Domain.Devices.Device.Changeset do
|
||||
@upsert_fields ~w[external_id name public_key]a
|
||||
@conflict_replace_fields ~w[public_key
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
last_seen_version last_seen_at
|
||||
updated_at]a
|
||||
@update_fields ~w[name]a
|
||||
@required_fields @upsert_fields
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ defmodule Domain.Gateways do
|
||||
def create_group(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
subject.account
|
||||
|> Group.Changeset.create_changeset(attrs)
|
||||
|> Group.Changeset.create_changeset(attrs, subject)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,8 @@ defmodule Domain.Gateways.Gateway.Changeset do
|
||||
last_seen_user_agent last_seen_remote_ip]a
|
||||
@conflict_replace_fields ~w[public_key
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
last_seen_version last_seen_at
|
||||
updated_at]a
|
||||
@update_fields ~w[name_suffix]a
|
||||
@required_fields @upsert_fields
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ defmodule Domain.Gateways.Group do
|
||||
|
||||
has_many :connections, Domain.Resources.Connection, foreign_key: :gateway_group_id
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
defmodule Domain.Gateways.Group.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.{Auth, Accounts}
|
||||
alias Domain.Gateways
|
||||
|
||||
@fields ~w[name_prefix tags]a
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
def create_changeset(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do
|
||||
%Gateways.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Gateways.Token.Changeset.create_changeset(account, subject)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
def update_changeset(%Gateways.Group{} = group, attrs) do
|
||||
@@ -32,12 +40,6 @@ defmodule Domain.Gateways.Group.Changeset do
|
||||
end)
|
||||
|> validate_required(@fields)
|
||||
|> unique_constraint(:name_prefix, name: :gateway_groups_account_id_name_prefix_index)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Gateways.Token.Changeset.create_changeset(group.account)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
def delete_changeset(%Gateways.Group{} = group) do
|
||||
|
||||
@@ -8,6 +8,9 @@ defmodule Domain.Gateways.Token do
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Gateways.Group
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
defmodule Domain.Gateways.Token.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
alias Domain.Accounts
|
||||
alias Domain.Gateways
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account) do
|
||||
def create_changeset(%Accounts.Account{} = account, %Auth.Subject{} = subject) do
|
||||
%Gateways.Token{}
|
||||
|> change()
|
||||
|> put_change(:account_id, account.id)
|
||||
@@ -11,6 +12,8 @@ defmodule Domain.Gateways.Token.Changeset do
|
||||
|> put_hash(:value, to: :hash)
|
||||
|> assoc_constraint(:group)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def use_changeset(%Gateways.Token{} = token) do
|
||||
|
||||
33
elixir/apps/domain/lib/domain/jobs.ex
Normal file
33
elixir/apps/domain/lib/domain/jobs.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule Domain.Jobs do
|
||||
@moduledoc """
|
||||
This module starts all recurrent job handlers defined by a module
|
||||
in individual processes and supervises them.
|
||||
"""
|
||||
use Supervisor
|
||||
|
||||
def start_link(module) do
|
||||
Supervisor.start_link(__MODULE__, module, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(module) do
|
||||
config = module.__config__()
|
||||
|
||||
children =
|
||||
Enum.flat_map(module.__handlers__(), fn {name, interval} ->
|
||||
handler_config = Keyword.get(config, name, [])
|
||||
|
||||
if Keyword.get(handler_config, :enabled, true) do
|
||||
[
|
||||
Supervisor.child_spec(
|
||||
{Domain.Jobs.Executors.Global, {{module, name}, interval, handler_config}},
|
||||
id: {module, name}
|
||||
)
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
164
elixir/apps/domain/lib/domain/jobs/executors/global.ex
Normal file
164
elixir/apps/domain/lib/domain/jobs/executors/global.ex
Normal file
@@ -0,0 +1,164 @@
|
||||
defmodule Domain.Jobs.Executors.Global do
|
||||
@moduledoc """
|
||||
This module is an abstraction on top of a GenServer that executes a callback function
|
||||
on a given interval on a globally unique process in an Erlang cluster.
|
||||
|
||||
It is mainly designed to run recurrent jobs that poll the database using a filter which
|
||||
prevents duplicate execution, eg:
|
||||
|
||||
SELECT *
|
||||
FROM my_table
|
||||
WHERE processed_at IS NULL
|
||||
AND processing_cancelled_at IS NULL
|
||||
LIMIT 100;
|
||||
|
||||
you can also keep manual track of retry attempts like so:
|
||||
|
||||
UPDATE my_table
|
||||
SET processing_attempts_count = processing_attempts_count + 1
|
||||
WHERE processed_at IS NULL
|
||||
AND processing_cancelled_at IS NULL
|
||||
AND processing_attempts_count < 3
|
||||
LIMIT 100
|
||||
RETURNING *;
|
||||
|
||||
and then updates the processing flag on job is completed:
|
||||
|
||||
UPDATE my_table SET processed_at = NOW() WHERE id = ?;
|
||||
|
||||
it is also recommended to cancel jobs to prevent them from being executed indefinitely:
|
||||
|
||||
UPDATE my_table SET processing_cancelled_at = NOW() WHERE id = ?;
|
||||
|
||||
Even though this does not provide a fully fledged job queue, it is a good enough solution
|
||||
for many use cases like refreshing tokens, deactivating users and even dispatching
|
||||
emails, while keeping the code simple, company-owned, maintainable and easy to reason about.
|
||||
|
||||
## Design Limitations
|
||||
|
||||
1. The interval is not guaranteed to be precise. The timer starts after the execution is
|
||||
finished, so next tick is always delayed by the execution time.
|
||||
|
||||
2. Because we don't persist the state the interval will be reset on every restart (eg. during deployment, or
|
||||
crash loops in your supervision tree), so the interval should not be too big.
|
||||
|
||||
3. The jobs must be idempotent. Callback is executed at least once in an erlang cluster and in the given interval,
|
||||
for example if you restart the cluster - the job execution can be repeated. That's why we don't have helpers
|
||||
that set interval in hours or more.
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
def start_link({{module, function}, interval, config}) do
|
||||
GenServer.start_link(__MODULE__, {{module, function}, interval, config})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({{module, function}, interval, config}) do
|
||||
name = global_name(module, function)
|
||||
|
||||
# `random_notify_name` is used to avoid name conflicts in a cluster during deployments and
|
||||
# network splits, it randomly selects one of the duplicate pids for registration,
|
||||
# and sends the message {global_name_conflict, Name} to the other pid so that they stop
|
||||
# trying to claim job queue leadership.
|
||||
with :no <- :global.register_name(name, self(), &:global.random_notify_name/3),
|
||||
pid when is_pid(pid) <- :global.whereis_name(name) do
|
||||
# we monitor the leader process so that we start a race to become a new leader when it's down
|
||||
monitor_ref = Process.monitor(pid)
|
||||
{:ok, {{{module, function}, interval, config}, {:fallback, pid, monitor_ref}}, :hibernate}
|
||||
else
|
||||
:yes ->
|
||||
Logger.debug("Recurrent job will be handled on this node",
|
||||
module: module,
|
||||
function: function
|
||||
)
|
||||
|
||||
initial_delay = Keyword.get(config, :initial_delay, 0)
|
||||
{:ok, {{{module, function}, interval, config}, :leader}, initial_delay}
|
||||
|
||||
:undefined ->
|
||||
Logger.warning("Recurrent job leader exists but is not yet available",
|
||||
module: module,
|
||||
function: function
|
||||
)
|
||||
|
||||
_timer_ref = :timer.sleep(100)
|
||||
init(module)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:timeout, {{{_module, _name}, interval, _config}, :leader} = state) do
|
||||
:ok = schedule_tick(interval)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:global_name_conflict, {__MODULE__, module, function}},
|
||||
{{{module, function}, interval, config}, _leader_or_fallback} = state
|
||||
) do
|
||||
name = global_name(module, function)
|
||||
|
||||
with pid when is_pid(pid) <- :global.whereis_name(name) do
|
||||
monitor_ref = Process.monitor(pid)
|
||||
state = {{{module, function}, interval, config}, {:fallback, pid, monitor_ref}}
|
||||
{:noreply, state, :hibernate}
|
||||
else
|
||||
:undefined ->
|
||||
Logger.warning("Recurrent job name conflict",
|
||||
module: module,
|
||||
function: function
|
||||
)
|
||||
|
||||
_timer_ref = :timer.sleep(100)
|
||||
handle_info({:global_name_conflict, module}, state)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
{:DOWN, _ref, :process, _pid, reason},
|
||||
{{{module, function}, interval, config}, {:fallback, pid, _monitor_ref}}
|
||||
) do
|
||||
# Solves the "Retry Storm" antipattern
|
||||
backoff_with_jitter = :rand.uniform(200) - 1
|
||||
_timer_ref = :timer.sleep(backoff_with_jitter)
|
||||
|
||||
Logger.info("Recurrent job leader is down",
|
||||
module: module,
|
||||
function: function,
|
||||
leader_pid: inspect(pid),
|
||||
leader_exit_reason: inspect(reason, pretty: true)
|
||||
)
|
||||
|
||||
case init({{module, function}, interval, config}) do
|
||||
{:ok, state, :hibernate} -> {:noreply, state, :hibernate}
|
||||
{:ok, state, _initial_delay} -> {:noreply, state, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:tick, {_definition, {:fallback, _pid, _monitor_ref}} = state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(:tick, {{{module, function}, interval, config}, :leader} = state) do
|
||||
:ok = execute_handler(module, function, config)
|
||||
:ok = schedule_tick(interval)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# tick is scheduled by using a timeout message instead of `:timer.send_interval/2`,
|
||||
# because we don't want jobs to overlap if they take too long to execute
|
||||
defp schedule_tick(interval) do
|
||||
_ = Process.send_after(self(), :tick, interval)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp execute_handler(module, function, config) do
|
||||
_ = apply(module, function, [config])
|
||||
:ok
|
||||
end
|
||||
|
||||
defp global_name(module, function), do: {__MODULE__, module, function}
|
||||
end
|
||||
56
elixir/apps/domain/lib/domain/jobs/recurrent.ex
Normal file
56
elixir/apps/domain/lib/domain/jobs/recurrent.ex
Normal file
@@ -0,0 +1,56 @@
|
||||
defmodule Domain.Jobs.Recurrent do
|
||||
@doc """
|
||||
This module provides a DSL to define recurrent jobs that run on an time interval basis.
|
||||
"""
|
||||
defmacro __using__(opts) do
|
||||
quote bind_quoted: [opts: opts] do
|
||||
import Domain.Jobs.Recurrent
|
||||
|
||||
# Accumulate handlers and define a `__handlers__/0` function to list them
|
||||
@before_compile Domain.Jobs.Recurrent
|
||||
Module.register_attribute(__MODULE__, :handlers, accumulate: true)
|
||||
|
||||
# Will read the config from the application environment
|
||||
@otp_app Keyword.fetch!(opts, :otp_app)
|
||||
@spec __config__() :: Keyword.t()
|
||||
def __config__, do: Domain.Config.get_env(@otp_app, __MODULE__, [])
|
||||
end
|
||||
end
|
||||
|
||||
defmacro __before_compile__(_env) do
|
||||
quote do
|
||||
@spec __handlers__() :: [{atom(), pos_integer()}]
|
||||
def __handlers__, do: @handlers
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Defines a code to execute every `interval` milliseconds.
|
||||
|
||||
Is it recommended to use `seconds/1`, `minutes/1` macros to define the interval.
|
||||
|
||||
Behind the hood it defines a function `execute(name, interval, do: ..)` and adds it's name to the
|
||||
module attribute.
|
||||
"""
|
||||
defmacro every(interval, name, do: block) do
|
||||
quote do
|
||||
@handlers {unquote(name), unquote(interval)}
|
||||
def unquote(name)(unquote(Macro.var(:_config, nil))), do: unquote(block)
|
||||
end
|
||||
end
|
||||
|
||||
defmacro every(interval, name, config, do: block) do
|
||||
quote do
|
||||
@handlers {unquote(name), unquote(interval)}
|
||||
def unquote(name)(unquote(config)), do: unquote(block)
|
||||
end
|
||||
end
|
||||
|
||||
def seconds(num) do
|
||||
:timer.seconds(num)
|
||||
end
|
||||
|
||||
def minutes(num) do
|
||||
:timer.minutes(num)
|
||||
end
|
||||
end
|
||||
@@ -43,7 +43,7 @@ defmodule Domain.Relays do
|
||||
def create_group(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
subject.account
|
||||
|> Group.Changeset.create_changeset(attrs)
|
||||
|> Group.Changeset.create_changeset(attrs, subject)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
@@ -69,7 +69,7 @@ defmodule Domain.Relays do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
group
|
||||
|> Repo.preload(:account)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
|> Group.Changeset.update_changeset(attrs, subject)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ defmodule Domain.Relays.Group do
|
||||
has_many :relays, Domain.Relays.Relay, foreign_key: :group_id
|
||||
has_many :tokens, Domain.Relays.Token, foreign_key: :group_id
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[system identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule Domain.Relays.Group.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
alias Domain.Accounts
|
||||
alias Domain.Relays
|
||||
|
||||
@@ -10,29 +11,42 @@ defmodule Domain.Relays.Group.Changeset do
|
||||
|> changeset(attrs)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Relays.Token.Changeset.create_changeset()
|
||||
Relays.Token.Changeset.create_changeset()
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
|> put_change(:created_by, :system)
|
||||
end
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
def create_changeset(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do
|
||||
%Relays.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Relays.Token.Changeset.create_changeset(account)
|
||||
Relays.Token.Changeset.create_changeset(account, subject)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def update_changeset(%Relays.Group{} = group, attrs, %Auth.Subject{} = subject) do
|
||||
changeset(group, attrs)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Relays.Token.Changeset.create_changeset(group.account, subject)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
def update_changeset(%Relays.Group{} = group, attrs) do
|
||||
changeset(group, attrs)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Relays.Token.Changeset.create_changeset(group.account)
|
||||
Relays.Token.Changeset.create_changeset()
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ defmodule Domain.Relays.Relay.Changeset do
|
||||
last_seen_user_agent last_seen_remote_ip]a
|
||||
@conflict_replace_fields ~w[ipv4 ipv6 port
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
last_seen_version last_seen_at
|
||||
updated_at]a
|
||||
|
||||
def upsert_conflict_target,
|
||||
do: {:unsafe_fragment, ~s/(account_id, COALESCE(ipv4, ipv6)) WHERE deleted_at IS NULL/}
|
||||
|
||||
@@ -8,6 +8,9 @@ defmodule Domain.Relays.Token do
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Relays.Group
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[system identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule Domain.Relays.Token.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
alias Domain.Accounts
|
||||
alias Domain.Relays
|
||||
|
||||
@@ -10,11 +11,14 @@ defmodule Domain.Relays.Token.Changeset do
|
||||
|> put_hash(:value, to: :hash)
|
||||
|> assoc_constraint(:group)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|
||||
|> put_change(:created_by, :system)
|
||||
end
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account) do
|
||||
def create_changeset(%Accounts.Account{} = account, %Auth.Subject{} = subject) do
|
||||
create_changeset()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def use_changeset(%Relays.Token{} = token) do
|
||||
|
||||
@@ -98,7 +98,7 @@ defmodule Domain.Resources do
|
||||
|
||||
def create_resource(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
|
||||
changeset = Resource.Changeset.create_changeset(subject.account, attrs)
|
||||
changeset = Resource.Changeset.create_changeset(subject.account, attrs, subject)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:resource, changeset, returning: true)
|
||||
@@ -145,7 +145,7 @@ defmodule Domain.Resources do
|
||||
def update_resource(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
|
||||
resource
|
||||
|> Resource.Changeset.update_changeset(attrs)
|
||||
|> Resource.Changeset.update_changeset(attrs, subject)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, resource} ->
|
||||
|
||||
@@ -6,6 +6,9 @@ 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[identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
defmodule Domain.Resources.Connection.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
|
||||
@fields ~w[gateway_group_id]a
|
||||
@required_fields @fields
|
||||
|
||||
def changeset(account_id, connection, attrs, %Auth.Subject{} = subject) do
|
||||
changeset(account_id, connection, attrs)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def changeset(account_id, connection, attrs) do
|
||||
connection
|
||||
|> cast(attrs, @fields)
|
||||
|
||||
@@ -19,6 +19,9 @@ defmodule Domain.Resources.Resource do
|
||||
has_many :connections, Domain.Resources.Connection, on_replace: :delete
|
||||
has_many :gateway_groups, through: [:connections, :gateway_group]
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[identity]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
defmodule Domain.Resources.Resource.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.{Accounts, Network}
|
||||
alias Domain.{Auth, Accounts, Network}
|
||||
alias Domain.Resources.{Resource, Connection}
|
||||
|
||||
@fields ~w[address name type]a
|
||||
@update_fields ~w[name]a
|
||||
@required_fields ~w[address type]a
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
def create_changeset(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do
|
||||
%Resource{}
|
||||
|> cast(attrs, @fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> changeset()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> validate_address()
|
||||
|> cast_assoc(:connections,
|
||||
with: &Connection.Changeset.changeset(account.id, &1, &2),
|
||||
with: &Connection.Changeset.changeset(account.id, &1, &2, subject),
|
||||
required: true
|
||||
)
|
||||
|> validate_address()
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def finalize_create_changeset(%Resource{} = resource, ipv4, ipv6) do
|
||||
@@ -71,13 +73,13 @@ defmodule Domain.Resources.Resource.Changeset do
|
||||
end
|
||||
end
|
||||
|
||||
def update_changeset(%Resource{} = resource, attrs) do
|
||||
def update_changeset(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do
|
||||
resource
|
||||
|> cast(attrs, @update_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> changeset()
|
||||
|> cast_assoc(:connections,
|
||||
with: &Connection.Changeset.changeset(resource.account_id, &1, &2),
|
||||
with: &Connection.Changeset.changeset(resource.account_id, &1, &2, subject),
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
@@ -2,10 +2,6 @@ defimpl String.Chars, for: Postgrex.INET do
|
||||
def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
|
||||
end
|
||||
|
||||
defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
|
||||
def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Postgrex.INET do
|
||||
def encode(%Postgrex.INET{} = struct, opts) do
|
||||
Jason.Encode.string("#{struct}", opts)
|
||||
@@ -15,7 +11,3 @@ end
|
||||
defimpl String.Chars, for: Domain.Types.IPPort do
|
||||
def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
|
||||
end
|
||||
|
||||
defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do
|
||||
def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
|
||||
end
|
||||
|
||||
@@ -390,6 +390,14 @@ defmodule Domain.Validator do
|
||||
end)
|
||||
end
|
||||
|
||||
def copy_change(changeset, from, to) do
|
||||
case fetch_change(changeset, from) do
|
||||
{:ok, nil} -> changeset
|
||||
{:ok, value} -> put_change(changeset, to, value)
|
||||
:error -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns `true` when binary representation of Ecto UUID is valid, otherwise - `false`.
|
||||
"""
|
||||
|
||||
@@ -52,7 +52,10 @@ defmodule Domain.Repo.Migrations.FixSitesNullableFields do
|
||||
|
||||
external_url =
|
||||
if is_nil(external_url_var) || String.length(external_url_var) == 0 do
|
||||
Logger.warn("EXTERNAL_URL is empty! Using #{substitute} as basis for WireGuard endpoint.")
|
||||
Logger.warning(
|
||||
"EXTERNAL_URL is empty! Using #{substitute} as basis for WireGuard endpoint."
|
||||
)
|
||||
|
||||
substitute
|
||||
else
|
||||
external_url_var
|
||||
@@ -61,7 +64,7 @@ defmodule Domain.Repo.Migrations.FixSitesNullableFields do
|
||||
parsed_host = URI.parse(external_url).host
|
||||
|
||||
if is_nil(parsed_host) do
|
||||
Logger.warn(
|
||||
Logger.warning(
|
||||
"EXTERNAL_URL doesn't seem to contain a valid URL. Assuming https://#{external_url}."
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ defmodule Domain.Repo.Migrations.CreateAuthIdentities do
|
||||
end
|
||||
|
||||
create(
|
||||
index(:auth_identities, [:provider_id, :provider_identifier],
|
||||
index(:auth_identities, [:account_id, :provider_id, :provider_identifier],
|
||||
name: :auth_identities_account_id_provider_id_provider_identifier_idx,
|
||||
where: "deleted_at IS NULL",
|
||||
unique: true
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
defmodule Domain.Repo.Migrations.CreateActorGroups do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:actor_groups, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
|
||||
add(:name, :string)
|
||||
|
||||
add(:provider_id, references(:auth_providers, type: :binary_id, on_delete: :delete_all))
|
||||
add(:provider_identifier, :string)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create(
|
||||
constraint(:actor_groups, :provider_fields_not_null,
|
||||
check: """
|
||||
(provider_id IS NOT NULL AND provider_identifier IS NOT NULL)
|
||||
OR (provider_id IS NULL AND provider_identifier IS NULL)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
create(index(:actor_groups, [:account_id], where: "deleted_at IS NULL"))
|
||||
|
||||
create(
|
||||
index(:actor_groups, [:account_id, :name],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND provider_id IS NULL AND provider_identifier IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(
|
||||
index(:actor_groups, [:account_id, :provider_id, :provider_identifier],
|
||||
unique: true,
|
||||
where:
|
||||
"deleted_at IS NULL AND provider_id IS NOT NULL AND provider_identifier IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create table(:actor_group_memberships, primary_key: false) do
|
||||
add(:actor_id, references(:actors, type: :binary_id, on_delete: :delete_all),
|
||||
primary_key: true,
|
||||
null: false
|
||||
)
|
||||
|
||||
add(:group_id, references(:actor_groups, type: :binary_id, on_delete: :delete_all),
|
||||
primary_key: true,
|
||||
null: false
|
||||
)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule Domain.Repo.Migrations.AddCreatedByFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:auth_providers) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:auth_identities) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:gateway_groups) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:gateway_tokens) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:relay_groups) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:relay_tokens) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:resources) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
|
||||
alter table(:resource_connections) do
|
||||
add(:created_by, :string, null: false)
|
||||
add(:created_by_identity_id, references(:auth_identities, type: :binary_id))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
defmodule Domain.Repo.Migrations.AddAuthProvidersGoogleWorkspaceFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:auth_providers) do
|
||||
add(:adapter_state, :map, default: %{}, null: false)
|
||||
add(:provisioner, :string, null: false)
|
||||
add(:last_synced_at, :utc_datetime_usec)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -44,7 +44,7 @@ IO.puts("")
|
||||
"client_id" => "CLIENT_ID",
|
||||
"client_secret" => "CLIENT_SECRET",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email offline_access",
|
||||
"scope" => "openid email profile",
|
||||
"discovery_document_uri" => "https://common.auth0.com/.well-known/openid-configuration"
|
||||
}
|
||||
})
|
||||
@@ -88,6 +88,33 @@ unprivileged_actor_userpass_identity =
|
||||
id: "7da7d1cd-111c-44a7-b5ac-4027b9d230e5"
|
||||
)
|
||||
|
||||
if client_secret = System.get_env("SEEDS_GOOGLE_OIDC_CLIENT_SECRET") do
|
||||
{:ok, google_provider} =
|
||||
Auth.create_provider(account, %{
|
||||
name: "Google Workspace",
|
||||
adapter: :google_workspace,
|
||||
adapter_config: %{
|
||||
"client_id" =>
|
||||
"1064313638613-0bttveunfv27l72s3h6th13kk16pj9l1.apps.googleusercontent.com",
|
||||
"client_secret" => client_secret
|
||||
}
|
||||
})
|
||||
|
||||
google_provider =
|
||||
Ecto.Changeset.change(google_provider, id: "8614a622-6c24-48aa-b1a4-2c6c04b6cbab")
|
||||
|> Repo.update!()
|
||||
|
||||
google_workspace_uid = System.get_env("SEEDS_GOOGLE_WORKSPACE_USER_ID")
|
||||
|
||||
{:ok, _admin_actor_google_workspace_identity} =
|
||||
Auth.create_identity(admin_actor, google_provider, google_workspace_uid, %{})
|
||||
|
||||
IO.puts("")
|
||||
IO.puts("Google Workspace provider: #{google_provider.id}")
|
||||
IO.puts(" User ID: #{google_workspace_uid}")
|
||||
IO.puts("")
|
||||
end
|
||||
|
||||
unprivileged_actor_token = hd(unprivileged_actor.identities).provider_virtual_state.sign_in_token
|
||||
admin_actor_token = hd(admin_actor.identities).provider_virtual_state.sign_in_token
|
||||
|
||||
@@ -121,7 +148,10 @@ IO.puts("")
|
||||
|
||||
relay_group =
|
||||
account
|
||||
|> Relays.Group.Changeset.create_changeset(%{name: "mycorp-aws-relays", tokens: [%{}]})
|
||||
|> Relays.Group.Changeset.create_changeset(
|
||||
%{name: "mycorp-aws-relays", tokens: [%{}]},
|
||||
admin_subject
|
||||
)
|
||||
|> Repo.insert!()
|
||||
|
||||
relay_group_token = hd(relay_group.tokens)
|
||||
@@ -153,7 +183,10 @@ IO.puts("")
|
||||
|
||||
gateway_group =
|
||||
account
|
||||
|> Gateways.Group.Changeset.create_changeset(%{name_prefix: "mycro-aws-gws", tokens: [%{}]})
|
||||
|> Gateways.Group.Changeset.create_changeset(
|
||||
%{name_prefix: "mycro-aws-gws", tokens: [%{}]},
|
||||
admin_subject
|
||||
)
|
||||
|> Repo.insert!()
|
||||
|
||||
gateway_group_token = hd(gateway_group.tokens)
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
defmodule Domain.AccountsTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Accounts
|
||||
alias Domain.{AccountsFixtures, AuthFixtures}
|
||||
alias Domain.Accounts
|
||||
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures}
|
||||
|
||||
describe "fetch_account_by_id/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when account does not exist", %{subject: subject} do
|
||||
assert fetch_account_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_account_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns account when account exists", %{account: account, subject: subject} do
|
||||
assert {:ok, fetched_account} = fetch_account_by_id(account.id, subject)
|
||||
assert fetched_account.id == account.id
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view accounts", %{subject: subject} do
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_account_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Accounts.Authorizer.view_accounts_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_account_by_id/1" do
|
||||
test "returns error when account is not found" do
|
||||
|
||||
@@ -5,6 +5,466 @@ defmodule Domain.ActorsTest do
|
||||
alias Domain.Actors
|
||||
alias Domain.{AccountsFixtures, AuthFixtures, ActorsFixtures}
|
||||
|
||||
describe "fetch_group_by_id/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_group_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = ActorsFixtures.create_group()
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group =
|
||||
ActorsFixtures.create_group(account: account)
|
||||
|> ActorsFixtures.delete_group()
|
||||
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns group by id", %{account: account, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns group that belongs to another actor", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns error when group does not exist", %{subject: subject} do
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_groups/1" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns empty list when there are no groups", %{subject: subject} do
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
ActorsFixtures.create_group()
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
ActorsFixtures.create_group(account: account)
|
||||
|> ActorsFixtures.delete_group()
|
||||
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
ActorsFixtures.create_group(account: account)
|
||||
ActorsFixtures.create_group(account: account)
|
||||
ActorsFixtures.create_group()
|
||||
|
||||
assert {:ok, groups} = list_groups(subject)
|
||||
assert length(groups) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_groups(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "new_group/0" do
|
||||
test "returns group changeset" do
|
||||
assert %Ecto.Changeset{data: %Actors.Group{}, changes: changes} = new_group()
|
||||
assert Enum.empty?(changes)
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_provider_group/3" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
%{
|
||||
bypass: bypass,
|
||||
account: account,
|
||||
provider: provider
|
||||
}
|
||||
end
|
||||
|
||||
# test "creates a new group", %{provider: provider} do
|
||||
# provider_identifier = Ecto.UUID.generate()
|
||||
# attrs_by_provider_identifier = %{provider_identifier => %{name: "foo"}}
|
||||
|
||||
# assert {:ok, group} = upsert_provider_group(provider, attrs)
|
||||
|
||||
# assert group.provider_identifier == provider_identifier
|
||||
# assert group.name == attrs.name
|
||||
|
||||
# assert group.provider_id == provider.id
|
||||
# assert group.account_id == provider.account_id
|
||||
# refute group.deleted_at
|
||||
|
||||
# assert Repo.one(Actors.Group)
|
||||
# end
|
||||
|
||||
# test "updates an existing group", %{account: account, provider: provider} do
|
||||
# group = ActorsFixtures.create_provider_group(account: account, provider: provider)
|
||||
|
||||
# provider_identifier = Ecto.UUID.generate()
|
||||
# attrs = %{name: "foo"}
|
||||
|
||||
# assert {:ok, updated_group} = upsert_provider_group_and_actors(provider, group.provider_identifier, attrs)
|
||||
|
||||
# assert updated_group.provider_identifier == provider_identifier
|
||||
# assert updated_group.name == group.name
|
||||
# assert updated_group.name != attrs.name
|
||||
|
||||
# assert updated_group.provider_id == provider.id
|
||||
# assert updated_group.account_id == provider.account_id
|
||||
# refute group.deleted_at
|
||||
|
||||
# assert Repo.one(Actors.Group)
|
||||
# end
|
||||
|
||||
# test "deletes existing groups that are not synced"
|
||||
|
||||
# updates membmers (removes old and adds new)
|
||||
end
|
||||
|
||||
describe "group_synced?/1" do
|
||||
test "returns true for synced groups" do
|
||||
account = AccountsFixtures.create_account()
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
group = ActorsFixtures.create_group(account: account, provider: provider)
|
||||
assert group_synced?(group)
|
||||
end
|
||||
|
||||
test "returns false for manually created groups" do
|
||||
group = ActorsFixtures.create_group()
|
||||
assert group_synced?(group) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_group/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on empty attrs", %{subject: subject} do
|
||||
assert {:error, changeset} = create_group(%{}, subject)
|
||||
assert errors_on(changeset) == %{name: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
attrs = %{name: String.duplicate("A", 65)}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert errors_on(changeset) == %{name: ["should be at most 64 character(s)"]}
|
||||
|
||||
ActorsFixtures.create_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo", tokens: [%{}]}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "creates a group", %{subject: subject} do
|
||||
attrs = ActorsFixtures.group_attrs()
|
||||
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == attrs.name
|
||||
|
||||
group = Repo.preload(group, :memberships)
|
||||
assert group.memberships == []
|
||||
end
|
||||
|
||||
test "creates a group with memberships", %{account: account, actor: actor, subject: subject} do
|
||||
attrs =
|
||||
ActorsFixtures.group_attrs(
|
||||
memberships: [
|
||||
%{actor_id: actor.id}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == attrs.name
|
||||
|
||||
group = Repo.preload(group, :memberships)
|
||||
assert [%Actors.Membership{} = membership] = group.memberships
|
||||
assert membership.actor_id == actor.id
|
||||
assert membership.account_id == account.id
|
||||
assert membership.group_id == group.id
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert create_group(%{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_group/1" do
|
||||
test "returns changeset with given changes" do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
group_attrs =
|
||||
ActorsFixtures.group_attrs(
|
||||
memberships: [
|
||||
%{actor_id: actor.id}
|
||||
]
|
||||
)
|
||||
|
||||
assert changeset = change_group(group, group_attrs)
|
||||
assert changeset.valid?
|
||||
|
||||
assert %{name: name, memberships: [membership]} = changeset.changes
|
||||
assert name == group_attrs.name
|
||||
assert membership.changes.account_id == account.id
|
||||
assert membership.changes.actor_id == actor.id
|
||||
end
|
||||
|
||||
test "raises if group is synced" do
|
||||
account = AccountsFixtures.create_account()
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
group = ActorsFixtures.create_group(account: account, provider: provider)
|
||||
|
||||
assert_raise ArgumentError, "can't change synced groups", fn ->
|
||||
change_group(group, %{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_group/3" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "does not allow to reset required fields to empty values", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = ActorsFixtures.create_group()
|
||||
attrs = %{name: nil}
|
||||
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{name: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
attrs = %{name: String.duplicate("A", 65)}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert errors_on(changeset) == %{name: ["should be at most 64 character(s)"]}
|
||||
|
||||
ActorsFixtures.create_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo"}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "updates a group", %{account: account, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
attrs = ActorsFixtures.group_attrs()
|
||||
assert {:ok, group} = update_group(group, attrs, subject)
|
||||
assert group.name == attrs.name
|
||||
end
|
||||
|
||||
test "updates group memberships", %{account: account, actor: actor, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account, memberships: [%{actor_id: actor.id}])
|
||||
|
||||
other_actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
|
||||
attrs =
|
||||
ActorsFixtures.group_attrs(
|
||||
memberships: [
|
||||
%{actor_id: other_actor.id}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, group} = update_group(group, attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == attrs.name
|
||||
|
||||
group = Repo.preload(group, :memberships)
|
||||
assert [%Actors.Membership{} = membership] = group.memberships
|
||||
assert membership.actor_id == other_actor.id
|
||||
assert membership.account_id == account.id
|
||||
assert membership.group_id == group.id
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_group(group, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
|
||||
end
|
||||
|
||||
test "raises if group is synced", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
group = ActorsFixtures.create_group(account: account, provider: provider)
|
||||
|
||||
assert update_group(group, %{}, subject) == {:error, :synced_group}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_group/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on state conflict", %{account: account, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert delete_group(deleted, subject) == {:error, :not_found}
|
||||
assert delete_group(group, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "deletes groups", %{account: account, subject: subject} do
|
||||
group = ActorsFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = ActorsFixtures.create_group()
|
||||
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_group(group, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Actors.Authorizer.manage_actors_permission()]]}}
|
||||
end
|
||||
|
||||
test "raises if group is synced", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
group = ActorsFixtures.create_group(account: account, provider: provider)
|
||||
|
||||
assert delete_group(group, subject) == {:error, :synced_group}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_count_by_type/0" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
@@ -56,6 +516,61 @@ defmodule Domain.ActorsTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_groups_count_grouped_by_provider_id/1" do
|
||||
test "returns empty map when there are no groups" do
|
||||
subject = AuthFixtures.create_subject()
|
||||
assert fetch_groups_count_grouped_by_provider_id(subject) == {:ok, %{}}
|
||||
end
|
||||
|
||||
test "returns count of actor groups by provider id" do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
{google_provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
{vault_provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["vault"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
ActorsFixtures.create_group(
|
||||
account: account,
|
||||
subject: subject
|
||||
)
|
||||
|
||||
ActorsFixtures.create_group(
|
||||
account: account,
|
||||
subject: subject,
|
||||
provider: google_provider,
|
||||
provider_identifier: Ecto.UUID.generate()
|
||||
)
|
||||
|
||||
ActorsFixtures.create_group(
|
||||
account: account,
|
||||
subject: subject,
|
||||
provider: vault_provider,
|
||||
provider_identifier: Ecto.UUID.generate()
|
||||
)
|
||||
|
||||
ActorsFixtures.create_group(
|
||||
account: account,
|
||||
subject: subject,
|
||||
provider: vault_provider,
|
||||
provider_identifier: Ecto.UUID.generate()
|
||||
)
|
||||
|
||||
assert fetch_groups_count_grouped_by_provider_id(subject) ==
|
||||
{:ok,
|
||||
%{
|
||||
google_provider.id => 1,
|
||||
vault_provider.id => 2
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_actor_by_id/2" do
|
||||
test "returns error when actor is not found" do
|
||||
subject = AuthFixtures.create_subject()
|
||||
@@ -233,7 +748,7 @@ defmodule Domain.ActorsTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on duplicate provider_identifier", %{
|
||||
test "upserts the identity based on unique provider_identifier", %{
|
||||
provider: provider
|
||||
} do
|
||||
provider_identifier = AuthFixtures.random_provider_identifier(provider)
|
||||
|
||||
@@ -40,27 +40,36 @@ defmodule Domain.Auth.Adapters.EmailTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned_for_account/2" do
|
||||
describe "provider_changeset/1" do
|
||||
test "returns changeset as is" do
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
|
||||
account = AccountsFixtures.create_account()
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_provisioned_for_account(changeset, account) == changeset
|
||||
changeset = %Ecto.Changeset{data: %Domain.Auth.Provider{account_id: account.id}}
|
||||
assert provider_changeset(changeset) == changeset
|
||||
end
|
||||
|
||||
test "returns error when email adapter is not configured" do
|
||||
account = AccountsFixtures.create_account()
|
||||
changeset = %Ecto.Changeset{}
|
||||
changeset = ensure_provisioned_for_account(changeset, account)
|
||||
changeset = %Ecto.Changeset{data: %Domain.Auth.Provider{account_id: account.id}}
|
||||
changeset = provider_changeset(changeset)
|
||||
assert changeset.errors == [adapter: {"email adapter is not configured", []}]
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
provider = AuthFixtures.create_email_provider()
|
||||
assert ensure_provisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "returns changeset as is" do
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_deprovisioned(changeset) == changeset
|
||||
test "does nothing for a provider" do
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
provider = AuthFixtures.create_email_provider()
|
||||
assert ensure_deprovisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
alias Domain.Mocks.GoogleWorkspaceDirectory
|
||||
import Domain.Auth.Adapters.GoogleWorkspace.APIClient
|
||||
|
||||
describe "list_users/1" do
|
||||
test "returns list of users" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.mock_users_list_endpoint(bypass)
|
||||
assert {:ok, users} = list_users(api_token)
|
||||
|
||||
assert length(users) == 4
|
||||
|
||||
for user <- users do
|
||||
assert Map.has_key?(user, "id")
|
||||
|
||||
# Profile fields
|
||||
assert Map.has_key?(user, "primaryEmail")
|
||||
assert Map.has_key?(user["name"], "fullName")
|
||||
|
||||
# Group fields
|
||||
assert Map.has_key?(user, "orgUnitPath")
|
||||
|
||||
# Policy fields
|
||||
assert Map.has_key?(user, "creationTime")
|
||||
assert Map.has_key?(user, "isEnforcedIn2Sv")
|
||||
assert Map.has_key?(user, "isEnrolledIn2Sv")
|
||||
end
|
||||
|
||||
assert_receive {:bypass_request, conn}
|
||||
|
||||
assert conn.params == %{
|
||||
"customer" => "my_customer",
|
||||
"fields" =>
|
||||
Enum.join(
|
||||
~w[
|
||||
users/id
|
||||
users/primaryEmail
|
||||
users/name/fullName
|
||||
users/orgUnitPath
|
||||
users/creationTime
|
||||
users/isEnforcedIn2Sv
|
||||
users/isEnrolledIn2Sv
|
||||
],
|
||||
","
|
||||
),
|
||||
"query" => "isSuspended=false isArchived=false",
|
||||
"showDeleted" => "false"
|
||||
}
|
||||
|
||||
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
|
||||
end
|
||||
|
||||
test "returns error when google api is down" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
Bypass.down(bypass)
|
||||
assert list_users(api_token) == {:error, %Mint.TransportError{reason: :econnrefused}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_organization_units/1" do
|
||||
test "returns list of organization units" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.mock_organization_units_list_endpoint(bypass)
|
||||
assert {:ok, organization_units} = list_organization_units(api_token)
|
||||
|
||||
assert length(organization_units) == 1
|
||||
|
||||
for organization_unit <- organization_units do
|
||||
assert Map.has_key?(organization_unit, "orgUnitPath")
|
||||
assert Map.has_key?(organization_unit, "orgUnitId")
|
||||
assert Map.has_key?(organization_unit, "name")
|
||||
end
|
||||
|
||||
assert_receive {:bypass_request, conn}
|
||||
assert conn.params == %{}
|
||||
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
|
||||
end
|
||||
|
||||
test "returns error when google api is down" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
Bypass.down(bypass)
|
||||
|
||||
assert list_organization_units(api_token) ==
|
||||
{:error, %Mint.TransportError{reason: :econnrefused}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_groups/1" do
|
||||
test "returns list of groups" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.mock_groups_list_endpoint(bypass)
|
||||
assert {:ok, groups} = list_groups(api_token)
|
||||
|
||||
assert length(groups) == 3
|
||||
|
||||
for group <- groups do
|
||||
assert Map.has_key?(group, "id")
|
||||
assert Map.has_key?(group, "name")
|
||||
end
|
||||
|
||||
assert_receive {:bypass_request, conn}
|
||||
|
||||
assert conn.params == %{
|
||||
"customer" => "my_customer"
|
||||
}
|
||||
|
||||
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
|
||||
end
|
||||
|
||||
test "returns error when google api is down" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
Bypass.down(bypass)
|
||||
assert list_groups(api_token) == {:error, %Mint.TransportError{reason: :econnrefused}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_group_members/1" do
|
||||
test "returns list of group members" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
group_id = Ecto.UUID.generate()
|
||||
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.mock_group_members_list_endpoint(bypass, group_id)
|
||||
assert {:ok, members} = list_group_members(api_token, group_id)
|
||||
|
||||
assert length(members) == 2
|
||||
|
||||
for member <- members do
|
||||
assert Map.has_key?(member, "id")
|
||||
assert Map.has_key?(member, "email")
|
||||
end
|
||||
|
||||
assert_receive {:bypass_request, conn}
|
||||
assert conn.params == %{}
|
||||
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
|
||||
end
|
||||
|
||||
test "returns error when google api is down" do
|
||||
api_token = Ecto.UUID.generate()
|
||||
group_id = Ecto.UUID.generate()
|
||||
|
||||
bypass = Bypass.open()
|
||||
GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
Bypass.down(bypass)
|
||||
|
||||
assert list_group_members(api_token, group_id) ==
|
||||
{:error, %Mint.TransportError{reason: :econnrefused}}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,284 @@
|
||||
defmodule Domain.Auth.Adapters.GoogleWorkspaceTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Auth.Adapters.GoogleWorkspace
|
||||
alias Domain.Auth
|
||||
alias Domain.Auth.Adapters.OpenIDConnect.PKCE
|
||||
alias Domain.{AccountsFixtures, AuthFixtures}
|
||||
|
||||
describe "identity_changeset/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_google_workspace_provider(account: account)
|
||||
|
||||
changeset = %Auth.Identity{} |> Ecto.Changeset.change()
|
||||
|
||||
%{
|
||||
bypass: bypass,
|
||||
account: account,
|
||||
provider: provider,
|
||||
changeset: changeset
|
||||
}
|
||||
end
|
||||
|
||||
test "puts default provider state", %{provider: provider, changeset: changeset} do
|
||||
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
|
||||
assert changeset.changes == %{provider_virtual_state: %{}}
|
||||
end
|
||||
|
||||
test "trims provider identifier", %{provider: provider, changeset: changeset} do
|
||||
changeset = Ecto.Changeset.put_change(changeset, :provider_identifier, " X ")
|
||||
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
|
||||
assert changeset.changes.provider_identifier == "X"
|
||||
end
|
||||
end
|
||||
|
||||
describe "provider_changeset/1" do
|
||||
test "returns changeset errors in invalid adapter config" do
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{}, %{})
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
assert errors_on(changeset) == %{adapter_config: ["can't be blank"]}
|
||||
|
||||
attrs = AuthFixtures.provider_attrs(adapter: :google_workspace, adapter_config: %{})
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{}, attrs)
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
adapter_config: %{
|
||||
client_id: ["can't be blank"],
|
||||
client_secret: ["can't be blank"]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
test "returns changeset on valid adapter config" do
|
||||
account = AccountsFixtures.create_account()
|
||||
{_bypass, discovery_document_uri} = AuthFixtures.discovery_document_server()
|
||||
|
||||
attrs =
|
||||
AuthFixtures.provider_attrs(
|
||||
adapter: :google_workspace,
|
||||
adapter_config: %{
|
||||
client_id: "client_id",
|
||||
client_secret: "client_secret",
|
||||
discovery_document_uri: discovery_document_uri
|
||||
}
|
||||
)
|
||||
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, attrs)
|
||||
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
assert {:ok, provider} = Repo.insert(changeset)
|
||||
|
||||
assert provider.name == attrs.name
|
||||
assert provider.adapter == attrs.adapter
|
||||
|
||||
assert provider.adapter_config == %{
|
||||
"scope" =>
|
||||
Enum.join(
|
||||
[
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.group.readonly",
|
||||
"https://www.googleapis.com/auth/admin.directory.user.readonly"
|
||||
],
|
||||
" "
|
||||
),
|
||||
"response_type" => "code",
|
||||
"client_id" => "client_id",
|
||||
"client_secret" => "client_secret",
|
||||
"discovery_document_uri" => discovery_document_uri
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
{provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_google_workspace_provider()
|
||||
|
||||
assert ensure_deprovisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
describe "verify_and_update_identity/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_google_workspace_provider(account: account)
|
||||
|
||||
identity = AuthFixtures.create_identity(account: account, provider: provider)
|
||||
|
||||
%{account: account, provider: provider, identity: identity, bypass: bypass}
|
||||
end
|
||||
|
||||
test "persists just the id token to adapter state", %{
|
||||
provider: provider,
|
||||
identity: identity,
|
||||
bypass: bypass
|
||||
} do
|
||||
{token, claims} = AuthFixtures.generate_openid_connect_token(provider, identity)
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token})
|
||||
AuthFixtures.expect_userinfo(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert {:ok, identity, expires_at} = verify_and_update_identity(provider, payload)
|
||||
|
||||
assert identity.provider_state == %{
|
||||
access_token: nil,
|
||||
claims: claims,
|
||||
expires_at: expires_at,
|
||||
refresh_token: nil,
|
||||
userinfo: %{
|
||||
"email" => "ada@example.com",
|
||||
"email_verified" => true,
|
||||
"family_name" => "Lovelace",
|
||||
"given_name" => "Ada",
|
||||
"locale" => "en",
|
||||
"name" => "Ada Lovelace",
|
||||
"picture" =>
|
||||
"https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg",
|
||||
"sub" => "353690423699814251281"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
test "persists all token details to the adapter state", %{
|
||||
provider: provider,
|
||||
identity: identity,
|
||||
bypass: bypass
|
||||
} do
|
||||
{token, _claims} = AuthFixtures.generate_openid_connect_token(provider, identity)
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{
|
||||
"token_type" => "Bearer",
|
||||
"id_token" => token,
|
||||
"access_token" => "MY_ACCESS_TOKEN",
|
||||
"refresh_token" => "MY_REFRESH_TOKEN",
|
||||
"expires_in" => 3600
|
||||
})
|
||||
|
||||
AuthFixtures.expect_userinfo(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert {:ok, identity, _expires_at} = verify_and_update_identity(provider, payload)
|
||||
|
||||
assert identity.provider_state.access_token == "MY_ACCESS_TOKEN"
|
||||
assert identity.provider_state.refresh_token == "MY_REFRESH_TOKEN"
|
||||
assert DateTime.diff(identity.provider_state.expires_at, DateTime.utc_now()) in 3595..3605
|
||||
end
|
||||
|
||||
test "returns error when token is expired", %{
|
||||
provider: provider,
|
||||
identity: identity,
|
||||
bypass: bypass
|
||||
} do
|
||||
forty_seconds_ago = DateTime.utc_now() |> DateTime.add(-40, :second) |> DateTime.to_unix()
|
||||
|
||||
{token, _claims} =
|
||||
AuthFixtures.generate_openid_connect_token(provider, identity, %{
|
||||
"exp" => forty_seconds_ago
|
||||
})
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token})
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :expired}
|
||||
end
|
||||
|
||||
test "returns error when token is invalid", %{
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
} do
|
||||
token = "foo"
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token})
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "returns error when identity does not exist", %{
|
||||
identity: identity,
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
} do
|
||||
{token, _claims} =
|
||||
AuthFixtures.generate_openid_connect_token(provider, identity, %{"sub" => "foo@bar.com"})
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{
|
||||
"token_type" => "Bearer",
|
||||
"id_token" => token,
|
||||
"access_token" => "MY_ACCESS_TOKEN",
|
||||
"refresh_token" => "MY_REFRESH_TOKEN",
|
||||
"expires_in" => 3600
|
||||
})
|
||||
|
||||
AuthFixtures.expect_userinfo(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when identity does not belong to provider", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
} do
|
||||
identity = AuthFixtures.create_identity(account: account)
|
||||
{token, _claims} = AuthFixtures.generate_openid_connect_token(provider, identity)
|
||||
|
||||
AuthFixtures.expect_refresh_token(bypass, %{
|
||||
"token_type" => "Bearer",
|
||||
"id_token" => token,
|
||||
"access_token" => "MY_ACCESS_TOKEN",
|
||||
"refresh_token" => "MY_REFRESH_TOKEN",
|
||||
"expires_in" => 3600
|
||||
})
|
||||
|
||||
AuthFixtures.expect_userinfo(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is down", %{
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
} do
|
||||
Bypass.down(bypass)
|
||||
|
||||
code_verifier = PKCE.code_verifier()
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :internal_error}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -35,16 +35,16 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned_for_account/2" do
|
||||
describe "provider_changeset/1" do
|
||||
test "returns changeset errors in invalid adapter config" do
|
||||
account = AccountsFixtures.create_account()
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, %{})
|
||||
assert %Ecto.Changeset{} = changeset = ensure_provisioned_for_account(changeset, account)
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
assert errors_on(changeset) == %{adapter_config: ["can't be blank"]}
|
||||
|
||||
attrs = AuthFixtures.provider_attrs(adapter: :openid_connect, adapter_config: %{})
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, attrs)
|
||||
assert %Ecto.Changeset{} = changeset = ensure_provisioned_for_account(changeset, account)
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
adapter_config: %{
|
||||
@@ -71,7 +71,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
|
||||
changeset = Ecto.Changeset.change(%Auth.Provider{account_id: account.id}, attrs)
|
||||
|
||||
assert %Ecto.Changeset{} = changeset = ensure_provisioned_for_account(changeset, account)
|
||||
assert %Ecto.Changeset{} = changeset = provider_changeset(changeset)
|
||||
assert {:ok, provider} = Repo.insert(changeset)
|
||||
|
||||
assert provider.name == attrs.name
|
||||
@@ -87,10 +87,27 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
assert ensure_provisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "returns changeset as is" do
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_deprovisioned(changeset) == changeset
|
||||
test "does nothing for a provider" do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
assert ensure_deprovisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -143,7 +160,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "verify_identity/2" do
|
||||
describe "verify_and_update_identity/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
@@ -170,7 +187,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert {:ok, identity, expires_at} = verify_identity(provider, payload)
|
||||
assert {:ok, identity, expires_at} = verify_and_update_identity(provider, payload)
|
||||
|
||||
assert identity.provider_state == %{
|
||||
access_token: nil,
|
||||
@@ -212,7 +229,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert {:ok, identity, _expires_at} = verify_identity(provider, payload)
|
||||
assert {:ok, identity, _expires_at} = verify_and_update_identity(provider, payload)
|
||||
|
||||
assert identity.provider_state.access_token == "MY_ACCESS_TOKEN"
|
||||
assert identity.provider_state.refresh_token == "MY_REFRESH_TOKEN"
|
||||
@@ -237,7 +254,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_identity(provider, payload) == {:error, :expired}
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :expired}
|
||||
end
|
||||
|
||||
test "returns error when token is invalid", %{
|
||||
@@ -252,7 +269,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_identity(provider, payload) == {:error, :invalid}
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "returns error when identity does not exist", %{
|
||||
@@ -277,7 +294,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_identity(provider, payload) == {:error, :not_found}
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when identity does not belong to provider", %{
|
||||
@@ -302,7 +319,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_identity(provider, payload) == {:error, :not_found}
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is down", %{
|
||||
@@ -315,7 +332,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
|
||||
redirect_uri = "https://example.com/"
|
||||
payload = {redirect_uri, code_verifier, "MyFakeCode"}
|
||||
|
||||
assert verify_identity(provider, payload) == {:error, :internal_error}
|
||||
assert verify_and_update_identity(provider, payload) == {:error, :internal_error}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -67,18 +67,24 @@ defmodule Domain.Auth.Adapters.TokenTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned_for_account/2" do
|
||||
describe "provider_changeset/1" do
|
||||
test "returns changeset as is" do
|
||||
account = AccountsFixtures.create_account()
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_provisioned_for_account(changeset, account) == changeset
|
||||
assert provider_changeset(changeset) == changeset
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
provider = AuthFixtures.create_token_provider()
|
||||
assert ensure_provisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "returns changeset as is" do
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_deprovisioned(changeset) == changeset
|
||||
test "does nothing for a provider" do
|
||||
provider = AuthFixtures.create_token_provider()
|
||||
assert ensure_deprovisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -91,18 +91,24 @@ defmodule Domain.Auth.Adapters.UserPassTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned_for_account/2" do
|
||||
describe "provider_changeset/1" do
|
||||
test "returns changeset as is" do
|
||||
account = AccountsFixtures.create_account()
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_provisioned_for_account(changeset, account) == changeset
|
||||
assert provider_changeset(changeset) == changeset
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_provisioned/1" do
|
||||
test "does nothing for a provider" do
|
||||
provider = AuthFixtures.create_userpass_provider()
|
||||
assert ensure_provisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensure_deprovisioned/1" do
|
||||
test "returns changeset as is" do
|
||||
changeset = %Ecto.Changeset{}
|
||||
assert ensure_deprovisioned(changeset) == changeset
|
||||
test "does nothing for a provider" do
|
||||
provider = AuthFixtures.create_userpass_provider()
|
||||
assert ensure_deprovisioned(provider) == {:ok, provider}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,6 +6,135 @@ defmodule Domain.AuthTest do
|
||||
alias Domain.Auth.Authorizer
|
||||
alias Domain.{AccountsFixtures, AuthFixtures}
|
||||
|
||||
describe "list_provider_adapters/0" do
|
||||
test "returns list of enabled adapters for an account" do
|
||||
assert {:ok, adapters} = list_provider_adapters()
|
||||
|
||||
assert adapters == %{
|
||||
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
|
||||
google_workspace: Domain.Auth.Adapters.GoogleWorkspace
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_provider_by_id/1" do
|
||||
test "returns error when provider does not exist" do
|
||||
assert fetch_provider_by_id(Ecto.UUID.generate()) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when on invalid UUIDv4" do
|
||||
assert fetch_provider_by_id("foo") == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is deleted" do
|
||||
account = AccountsFixtures.create_account()
|
||||
AuthFixtures.create_userpass_provider(account: account)
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
provider = AuthFixtures.create_email_provider(account: account)
|
||||
|
||||
identity =
|
||||
AuthFixtures.create_identity(
|
||||
actor_default_type: :account_admin_user,
|
||||
account: account,
|
||||
provider: provider
|
||||
)
|
||||
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
{:ok, _provider} = delete_provider(provider, subject)
|
||||
|
||||
assert fetch_provider_by_id(provider.id) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns provider" do
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
provider = AuthFixtures.create_email_provider()
|
||||
assert {:ok, fetched_provider} = fetch_provider_by_id(provider.id)
|
||||
assert fetched_provider.id == provider.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_provider_by_id/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(account: account, type: :account_admin_user)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when provider does not exist", %{subject: subject} do
|
||||
assert fetch_provider_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when on invalid UUIDv4", %{subject: subject} do
|
||||
assert fetch_provider_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is deleted", %{account: account, subject: subject} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
{:ok, _provider} = delete_provider(provider, subject)
|
||||
|
||||
assert fetch_provider_by_id(provider.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns provider", %{account: account, subject: subject} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
assert {:ok, fetched_provider} = fetch_provider_by_id(provider.id, subject)
|
||||
assert fetched_provider.id == provider.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_active_provider_by_id/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(account: account, type: :account_admin_user)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when provider does not exist", %{subject: subject} do
|
||||
assert fetch_active_provider_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when on invalid UUIDv4", %{subject: subject} do
|
||||
assert fetch_active_provider_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is disabled", %{account: account, subject: subject} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
{:ok, _provider} = disable_provider(provider, subject)
|
||||
assert fetch_active_provider_by_id(provider.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when provider is deleted", %{account: account, subject: subject} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
{:ok, _provider} = delete_provider(provider, subject)
|
||||
|
||||
assert fetch_active_provider_by_id(provider.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns provider", %{account: account, subject: subject} do
|
||||
provider = AuthFixtures.create_userpass_provider(account: account)
|
||||
assert {:ok, fetched_provider} = fetch_active_provider_by_id(provider.id, subject)
|
||||
assert fetched_provider.id == provider.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_active_provider_by_id/1" do
|
||||
test "returns error when provider does not exist" do
|
||||
assert fetch_active_provider_by_id(Ecto.UUID.generate()) == {:error, :not_found}
|
||||
@@ -57,6 +186,46 @@ defmodule Domain.AuthTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_providers_for_account/2" do
|
||||
test "returns all not soft-deleted providers for a given account" do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
AuthFixtures.create_userpass_provider(account: account)
|
||||
email_provider = AuthFixtures.create_email_provider(account: account)
|
||||
token_provider = AuthFixtures.create_token_provider(account: account)
|
||||
|
||||
identity =
|
||||
AuthFixtures.create_identity(
|
||||
actor_default_type: :account_admin_user,
|
||||
account: account,
|
||||
provider: email_provider
|
||||
)
|
||||
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
{:ok, _provider} = disable_provider(token_provider, subject)
|
||||
{:ok, _provider} = delete_provider(email_provider, subject)
|
||||
|
||||
assert {:ok, providers} = list_providers_for_account(account, subject)
|
||||
assert length(providers) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject can not manage providers" do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
identity =
|
||||
AuthFixtures.create_identity(actor_default_type: :account_admin_user, account: account)
|
||||
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_providers_for_account(account, subject) ==
|
||||
{:error,
|
||||
{:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_active_providers_for_account/1" do
|
||||
test "returns active providers for a given account" do
|
||||
account = AccountsFixtures.create_account()
|
||||
@@ -83,6 +252,45 @@ defmodule Domain.AuthTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "new_provider/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{bypass, [provider_adapter_config]} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|
||||
%{
|
||||
account: account,
|
||||
provider_adapter_config: provider_adapter_config,
|
||||
bypass: bypass
|
||||
}
|
||||
end
|
||||
|
||||
test "returns changeset with given changes", %{
|
||||
account: account,
|
||||
provider_adapter_config: provider_adapter_config
|
||||
} do
|
||||
assert changeset = new_provider(account)
|
||||
assert %Ecto.Changeset{data: %Domain.Auth.Provider{}} = changeset
|
||||
assert changeset.changes == %{account_id: account.id, created_by: :system}
|
||||
|
||||
provider_attrs =
|
||||
AuthFixtures.provider_attrs(
|
||||
adapter: :openid_connect,
|
||||
adapter_config: provider_adapter_config
|
||||
)
|
||||
|
||||
assert changeset = new_provider(account, provider_attrs)
|
||||
assert %Ecto.Changeset{data: %Domain.Auth.Provider{}} = changeset
|
||||
assert changeset.changes.name == provider_attrs.name
|
||||
assert changeset.changes.provisioner == provider_attrs.provisioner
|
||||
assert changeset.changes.adapter == provider_attrs.adapter
|
||||
|
||||
assert changeset.changes.adapter_config.changes.client_id ==
|
||||
provider_attrs.adapter_config["client_id"]
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_provider/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
@@ -101,7 +309,8 @@ defmodule Domain.AuthTest do
|
||||
assert errors_on(changeset) == %{
|
||||
adapter: ["can't be blank"],
|
||||
adapter_config: ["can't be blank"],
|
||||
name: ["can't be blank"]
|
||||
name: ["can't be blank"],
|
||||
provisioner: ["can't be blank"]
|
||||
}
|
||||
end
|
||||
|
||||
@@ -133,7 +342,7 @@ defmodule Domain.AuthTest do
|
||||
attrs = AuthFixtures.provider_attrs(adapter: :email)
|
||||
assert {:error, changeset} = create_provider(account, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset) == %{adapter: ["this provider is already enabled"]}
|
||||
assert errors_on(changeset) == %{base: ["this provider is already enabled"]}
|
||||
end
|
||||
|
||||
test "returns error if userpass provider is already enabled", %{
|
||||
@@ -143,7 +352,7 @@ defmodule Domain.AuthTest do
|
||||
attrs = AuthFixtures.provider_attrs(adapter: :userpass)
|
||||
assert {:error, changeset} = create_provider(account, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset) == %{adapter: ["this provider is already enabled"]}
|
||||
assert errors_on(changeset) == %{base: ["this provider is already enabled"]}
|
||||
end
|
||||
|
||||
test "returns error if token provider is already enabled", %{
|
||||
@@ -153,7 +362,7 @@ defmodule Domain.AuthTest do
|
||||
attrs = AuthFixtures.provider_attrs(adapter: :token)
|
||||
assert {:error, changeset} = create_provider(account, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset) == %{adapter: ["this provider is already enabled"]}
|
||||
assert errors_on(changeset) == %{base: ["this provider is already enabled"]}
|
||||
end
|
||||
|
||||
test "returns error if openid connect provider is already enabled", %{
|
||||
@@ -166,12 +375,13 @@ defmodule Domain.AuthTest do
|
||||
attrs =
|
||||
AuthFixtures.provider_attrs(
|
||||
adapter: :openid_connect,
|
||||
adapter_config: provider.adapter_config
|
||||
adapter_config: provider.adapter_config,
|
||||
provisioner: :just_in_time
|
||||
)
|
||||
|
||||
assert {:error, changeset} = create_provider(account, attrs)
|
||||
refute changeset.valid?
|
||||
assert errors_on(changeset) == %{adapter: ["this provider is already connected"]}
|
||||
assert errors_on(changeset) == %{base: ["this provider is already connected"]}
|
||||
end
|
||||
|
||||
test "creates a provider", %{
|
||||
@@ -187,6 +397,10 @@ defmodule Domain.AuthTest do
|
||||
assert provider.adapter == attrs.adapter
|
||||
assert provider.adapter_config == attrs.adapter_config
|
||||
assert provider.account_id == account.id
|
||||
|
||||
assert provider.created_by == :system
|
||||
assert is_nil(provider.created_by_identity_id)
|
||||
|
||||
assert is_nil(provider.disabled_at)
|
||||
assert is_nil(provider.deleted_at)
|
||||
end
|
||||
@@ -223,7 +437,7 @@ defmodule Domain.AuthTest do
|
||||
{:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}}
|
||||
end
|
||||
|
||||
test "returns error when subject tries to create an account in another account", %{
|
||||
test "returns error when subject tries to create a provider in another account", %{
|
||||
account: other_account
|
||||
} do
|
||||
account = AccountsFixtures.create_account()
|
||||
@@ -233,6 +447,148 @@ defmodule Domain.AuthTest do
|
||||
|
||||
assert create_provider(other_account, %{}, subject) == {:error, :unauthorized}
|
||||
end
|
||||
|
||||
test "persists identity that created the provider", %{account: account} do
|
||||
attrs = AuthFixtures.provider_attrs()
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
|
||||
actor = ActorsFixtures.create_actor(account: account, type: :account_admin_user)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
assert {:ok, provider} = create_provider(account, attrs, subject)
|
||||
|
||||
assert provider.created_by == :identity
|
||||
assert provider.created_by_identity_id == subject.identity.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_provider/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
|
||||
{provider, bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_google_workspace_provider(account: account)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
provider: provider,
|
||||
bypass: bypass
|
||||
}
|
||||
end
|
||||
|
||||
test "returns changeset with given changes", %{provider: provider} do
|
||||
provider_attrs = AuthFixtures.provider_attrs()
|
||||
|
||||
assert changeset = change_provider(provider, provider_attrs)
|
||||
assert %Ecto.Changeset{data: %Domain.Auth.Provider{}} = changeset
|
||||
|
||||
assert changeset.changes.name == provider_attrs.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_provider/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(account: account, type: :account_admin_user)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
{provider, bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_google_workspace_provider(account: account)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
actor: actor,
|
||||
identity: identity,
|
||||
provider: provider,
|
||||
bypass: bypass,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns changeset error when required attrs are missing", %{
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
attrs = %{name: nil, adapter: nil, adapter_config: nil}
|
||||
assert {:error, changeset} = update_provider(provider, attrs, subject)
|
||||
refute changeset.valid?
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
adapter_config: ["can't be blank"],
|
||||
name: ["can't be blank"]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
attrs =
|
||||
AuthFixtures.provider_attrs(
|
||||
name: String.duplicate("A", 256),
|
||||
adapter: :foo,
|
||||
adapter_config: :bar,
|
||||
provisioner: :foo
|
||||
)
|
||||
|
||||
assert {:error, changeset} = update_provider(provider, attrs, subject)
|
||||
refute changeset.valid?
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["should be at most 255 character(s)"],
|
||||
adapter_config: ["is invalid"],
|
||||
provisioner: ["is invalid"]
|
||||
}
|
||||
end
|
||||
|
||||
test "updates a provider", %{
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
attrs =
|
||||
AuthFixtures.provider_attrs(
|
||||
provisioner: :custom,
|
||||
adapter_config: %{
|
||||
client_id: "foo"
|
||||
}
|
||||
)
|
||||
|
||||
assert {:ok, provider} = update_provider(provider, attrs, subject)
|
||||
|
||||
assert provider.name == attrs.name
|
||||
assert provider.adapter == provider.adapter
|
||||
assert provider.adapter_config["client_id"] == attrs.adapter_config.client_id
|
||||
assert provider.account_id == subject.account.id
|
||||
|
||||
assert is_nil(provider.disabled_at)
|
||||
assert is_nil(provider.deleted_at)
|
||||
end
|
||||
|
||||
test "returns error when subject can not manage providers", %{
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
subject = AuthFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_provider(provider, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized, [missing_permissions: [Authorizer.manage_providers_permission()]]}}
|
||||
end
|
||||
|
||||
test "returns error when subject tries to update an account in another account", %{
|
||||
provider: provider
|
||||
} do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(account: account, type: :account_admin_user)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
assert update_provider(provider, %{}, subject) == {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
describe "disable_provider/2" do
|
||||
@@ -576,6 +932,18 @@ defmodule Domain.AuthTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_provider_capabilities!/1" do
|
||||
test "returns provider capabilities" do
|
||||
provider = AuthFixtures.create_userpass_provider()
|
||||
|
||||
assert fetch_provider_capabilities!(provider) == [
|
||||
provisioners: [:manual],
|
||||
default_provisioner: :manual,
|
||||
parent_adapter: nil
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_identity_by_id/1" do
|
||||
test "returns error when identity does not exist" do
|
||||
assert fetch_identity_by_id(Ecto.UUID.generate()) == {:error, :not_found}
|
||||
@@ -588,7 +956,36 @@ defmodule Domain.AuthTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_identity/3" do
|
||||
describe "fetch_identities_count_grouped_by_provider_id/1" do
|
||||
test "returns count of actor identities by provider id" do
|
||||
account = AccountsFixtures.create_account()
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
|
||||
{google_provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["google"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
{vault_provider, _bypass} =
|
||||
AuthFixtures.start_openid_providers(["vault"])
|
||||
|> AuthFixtures.create_openid_connect_provider(account: account)
|
||||
|
||||
AuthFixtures.create_identity(account: account, provider: google_provider)
|
||||
AuthFixtures.create_identity(account: account, provider: vault_provider)
|
||||
AuthFixtures.create_identity(account: account, provider: vault_provider)
|
||||
|
||||
assert fetch_identities_count_grouped_by_provider_id(subject) ==
|
||||
{:ok,
|
||||
%{
|
||||
identity.provider_id => 1,
|
||||
google_provider.id => 1,
|
||||
vault_provider.id => 2
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_identity/3" do
|
||||
test "creates an identity" do
|
||||
account = AccountsFixtures.create_account()
|
||||
Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark)
|
||||
@@ -602,7 +999,7 @@ defmodule Domain.AuthTest do
|
||||
provider: provider
|
||||
)
|
||||
|
||||
assert {:ok, identity} = create_identity(actor, provider, provider_identifier)
|
||||
assert {:ok, identity} = upsert_identity(actor, provider, provider_identifier)
|
||||
|
||||
assert identity.provider_id == provider.id
|
||||
assert identity.provider_identifier == provider_identifier
|
||||
@@ -629,11 +1026,11 @@ defmodule Domain.AuthTest do
|
||||
)
|
||||
|
||||
provider_identifier = Ecto.UUID.generate()
|
||||
assert {:error, changeset} = create_identity(actor, provider, provider_identifier)
|
||||
assert {:error, changeset} = upsert_identity(actor, provider, provider_identifier)
|
||||
assert errors_on(changeset) == %{provider_identifier: ["is an invalid email address"]}
|
||||
|
||||
provider_identifier = nil
|
||||
assert {:error, changeset} = create_identity(actor, provider, provider_identifier)
|
||||
assert {:error, changeset} = upsert_identity(actor, provider, provider_identifier)
|
||||
assert errors_on(changeset) == %{provider_identifier: ["can't be blank"]}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -173,7 +173,13 @@ defmodule Domain.GatewaysTest do
|
||||
assert group.id
|
||||
assert group.name_prefix == "foo"
|
||||
assert group.tags == ["bar"]
|
||||
assert [%Gateways.Token{}] = group.tokens
|
||||
|
||||
assert group.created_by == :identity
|
||||
assert group.created_by_identity_id == subject.identity.id
|
||||
|
||||
assert [%Gateways.Token{} = token] = group.tokens
|
||||
assert token.created_by == :identity
|
||||
assert token.created_by_identity_id == subject.identity.id
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
defmodule Domain.Jobs.Executors.GlobalTest do
|
||||
use ExUnit.Case, async: true
|
||||
import Domain.Jobs.Executors.Global
|
||||
|
||||
def send_test_message(config) do
|
||||
send(config[:test_pid], {:executed, self(), :erlang.monotonic_time()})
|
||||
:ok
|
||||
end
|
||||
|
||||
test "executes the handler on the interval" do
|
||||
assert {:ok, _pid} = start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
assert_receive {:executed, _pid, time1}
|
||||
assert_receive {:executed, _pid, time2}
|
||||
|
||||
assert time1 < time2
|
||||
end
|
||||
|
||||
test "delays initial message by the initial_delay" do
|
||||
assert {:ok, _pid} =
|
||||
start_link({
|
||||
{__MODULE__, :send_test_message},
|
||||
25,
|
||||
test_pid: self(), initial_delay: 100
|
||||
})
|
||||
|
||||
refute_receive {:executed, _pid, _time}, 50
|
||||
assert_receive {:executed, _pid, _time}
|
||||
end
|
||||
|
||||
test "registers itself as a leader if there is no global name registered" do
|
||||
assert {:ok, pid} = start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
name = {Domain.Jobs.Executors.Global, __MODULE__, :send_test_message}
|
||||
assert :global.whereis_name(name) == pid
|
||||
|
||||
assert :sys.get_state(pid) ==
|
||||
{
|
||||
{
|
||||
{__MODULE__, :send_test_message},
|
||||
25,
|
||||
[test_pid: self()]
|
||||
},
|
||||
:leader
|
||||
}
|
||||
end
|
||||
|
||||
test "other processes register themselves as fallbacks and monitor the leader" do
|
||||
assert {:ok, leader_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
assert {:ok, fallback1_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
assert {:ok, fallback2_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
name = {Domain.Jobs.Executors.Global, __MODULE__, :send_test_message}
|
||||
assert :global.whereis_name(name) == leader_pid
|
||||
|
||||
assert {_state, {:fallback, ^leader_pid, _monitor_ref}} = :sys.get_state(fallback1_pid)
|
||||
assert {_state, {:fallback, ^leader_pid, _monitor_ref}} = :sys.get_state(fallback2_pid)
|
||||
end
|
||||
|
||||
test "other processes register a new leader when old one is down" do
|
||||
assert {:ok, leader_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
assert {:ok, fallback1_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
assert {:ok, fallback2_pid} =
|
||||
start_link({{__MODULE__, :send_test_message}, 25, test_pid: self()})
|
||||
|
||||
Process.flag(:trap_exit, true)
|
||||
Process.exit(leader_pid, :kill)
|
||||
assert_receive {:EXIT, ^leader_pid, :killed}
|
||||
|
||||
%{leader: [new_leader_pid], fallback: [fallback_pid]} =
|
||||
Enum.group_by([fallback1_pid, fallback2_pid], fn pid ->
|
||||
case :sys.get_state(pid) do
|
||||
{_state, {:fallback, _leader_pid, _monitor_ref}} -> :fallback
|
||||
{_state, :leader} -> :leader
|
||||
end
|
||||
end)
|
||||
|
||||
assert {_state, {:fallback, ^new_leader_pid, _monitor_ref}} = :sys.get_state(fallback_pid)
|
||||
assert {_state, :leader} = :sys.get_state(new_leader_pid)
|
||||
|
||||
name = {Domain.Jobs.Executors.Global, __MODULE__, :send_test_message}
|
||||
assert :global.whereis_name(name) == new_leader_pid
|
||||
|
||||
assert_receive {:executed, ^new_leader_pid, _time}
|
||||
end
|
||||
end
|
||||
43
elixir/apps/domain/test/domain/jobs/recurrent_test.exs
Normal file
43
elixir/apps/domain/test/domain/jobs/recurrent_test.exs
Normal file
@@ -0,0 +1,43 @@
|
||||
defmodule Domain.Jobs.RecurrentTest do
|
||||
use ExUnit.Case, async: true
|
||||
import Domain.Jobs.Recurrent
|
||||
|
||||
defmodule TestDefinition do
|
||||
use Domain.Jobs.Recurrent, otp_app: :domain
|
||||
require Logger
|
||||
|
||||
every seconds(1), :second_test, config do
|
||||
send(config[:test_pid], :executed)
|
||||
end
|
||||
|
||||
every minutes(5), :minute_test do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
describe "seconds/1" do
|
||||
test "converts seconds to milliseconds" do
|
||||
assert seconds(1) == 1_000
|
||||
assert seconds(13) == 13_000
|
||||
end
|
||||
end
|
||||
|
||||
describe "minutes/1" do
|
||||
test "converts minutes to milliseconds" do
|
||||
assert minutes(1) == 60_000
|
||||
assert minutes(13) == 780_000
|
||||
end
|
||||
end
|
||||
|
||||
test "defines callbacks" do
|
||||
assert length(TestDefinition.__handlers__()) == 2
|
||||
|
||||
assert {:minute_test, 300_000} in TestDefinition.__handlers__()
|
||||
assert {:second_test, 1000} in TestDefinition.__handlers__()
|
||||
|
||||
assert TestDefinition.minute_test(test_pid: self()) == :ok
|
||||
|
||||
assert TestDefinition.second_test(test_pid: self()) == :executed
|
||||
assert_receive :executed
|
||||
end
|
||||
end
|
||||
@@ -175,7 +175,13 @@ defmodule Domain.RelaysTest do
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == "foo"
|
||||
assert [%Relays.Token{}] = group.tokens
|
||||
|
||||
assert group.created_by == :identity
|
||||
assert group.created_by_identity_id == subject.identity.id
|
||||
|
||||
assert [%Relays.Token{} = token] = group.tokens
|
||||
assert token.created_by == :identity
|
||||
assert token.created_by_identity_id == subject.identity.id
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
@@ -223,7 +229,13 @@ defmodule Domain.RelaysTest do
|
||||
assert {:ok, group} = create_global_group(attrs)
|
||||
assert group.id
|
||||
assert group.name == "foo"
|
||||
assert [%Relays.Token{}] = group.tokens
|
||||
|
||||
assert group.created_by == :system
|
||||
assert is_nil(group.created_by_identity_id)
|
||||
|
||||
assert [%Relays.Token{} = token] = group.tokens
|
||||
assert token.created_by == :system
|
||||
assert is_nil(token.created_by_identity_id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -430,17 +430,15 @@ defmodule Domain.ResourcesTest do
|
||||
refute is_nil(resource.ipv4)
|
||||
refute is_nil(resource.ipv6)
|
||||
|
||||
assert [
|
||||
%Domain.Resources.Connection{
|
||||
resource_id: resource_id,
|
||||
gateway_group_id: gateway_group_id,
|
||||
account_id: account_id
|
||||
}
|
||||
] = resource.connections
|
||||
assert resource.created_by == :identity
|
||||
assert resource.created_by_identity_id == subject.identity.id
|
||||
|
||||
assert resource_id == resource.id
|
||||
assert gateway_group_id == gateway.group_id
|
||||
assert account_id == account.id
|
||||
assert [%Domain.Resources.Connection{} = connection] = resource.connections
|
||||
assert connection.resource_id == resource.id
|
||||
assert connection.gateway_group_id == gateway.group_id
|
||||
assert connection.account_id == account.id
|
||||
assert connection.created_by == :identity
|
||||
assert connection.created_by_identity_id == subject.identity.id
|
||||
|
||||
assert [
|
||||
%Domain.Resources.Resource.Filter{ports: ["80", "433"], protocol: :tcp},
|
||||
|
||||
@@ -3,6 +3,81 @@ defmodule Domain.ActorsFixtures do
|
||||
alias Domain.Actors
|
||||
alias Domain.{AccountsFixtures, AuthFixtures}
|
||||
|
||||
def group_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name: "group-#{counter()}"
|
||||
})
|
||||
end
|
||||
|
||||
def create_group(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{provider, attrs} =
|
||||
Map.pop(attrs, :provider)
|
||||
|
||||
{provider_identifier, attrs} =
|
||||
Map.pop_lazy(attrs, :provider_identifier, fn ->
|
||||
Ecto.UUID.generate()
|
||||
end)
|
||||
|
||||
{subject, attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
actor = create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
AuthFixtures.create_subject(identity)
|
||||
end)
|
||||
|
||||
attrs = group_attrs(attrs)
|
||||
|
||||
{:ok, group} = Actors.create_group(attrs, subject)
|
||||
|
||||
if provider do
|
||||
group
|
||||
|> Ecto.Changeset.change(provider_id: provider.id, provider_identifier: provider_identifier)
|
||||
|> Repo.update!()
|
||||
else
|
||||
group
|
||||
end
|
||||
end
|
||||
|
||||
# def create_provider_group(attrs \\ %{}) do
|
||||
# attrs = Enum.into(attrs, %{})
|
||||
|
||||
# {account, attrs} =
|
||||
# Map.pop_lazy(attrs, :account, fn ->
|
||||
# AccountsFixtures.create_account()
|
||||
# end)
|
||||
|
||||
# {provider_identifier, attrs} =
|
||||
# Map.pop_lazy(attrs, :provider_identifier, fn ->
|
||||
# Ecto.UUID.generate()
|
||||
# end)
|
||||
|
||||
# {provider, attrs} =
|
||||
# Map.pop_lazy(attrs, :account, fn ->
|
||||
# AccountsFixtures.create_account()
|
||||
# end)
|
||||
|
||||
# attrs = group_attrs(attrs)
|
||||
|
||||
# {:ok, group} = Actors.upsert_provider_group(provider, provider_identifier, attrs)
|
||||
# group
|
||||
# end
|
||||
|
||||
def delete_group(group) do
|
||||
group = Repo.preload(group, :account)
|
||||
actor = create_actor(type: :account_admin_user, account: group.account)
|
||||
identity = AuthFixtures.create_identity(account: group.account, actor: actor)
|
||||
subject = AuthFixtures.create_subject(identity)
|
||||
{:ok, group} = Actors.delete_group(group, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def actor_attrs(attrs \\ %{}) do
|
||||
first_name = Enum.random(~w[Wade Dave Seth Riley Gilbert Jorge Dan Brian Roberto Ramon])
|
||||
last_name = Enum.random(~w[Robyn Traci Desiree Jon Bob Karl Joe Alberta Lynda Cara Brandi])
|
||||
@@ -32,7 +107,7 @@ defmodule Domain.ActorsFixtures do
|
||||
|
||||
attrs = actor_attrs(attrs)
|
||||
|
||||
Actors.Actor.Changeset.create_changeset(provider, attrs)
|
||||
Actors.Actor.Changeset.create_changeset(provider.account_id, attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
@@ -49,4 +124,8 @@ defmodule Domain.ActorsFixtures do
|
||||
def delete(actor) do
|
||||
update(actor, %{deleted_at: DateTime.utc_now()})
|
||||
end
|
||||
|
||||
defp counter do
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,10 @@ defmodule Domain.AuthFixtures do
|
||||
Ecto.UUID.generate()
|
||||
end
|
||||
|
||||
def random_provider_identifier(%Domain.Auth.Provider{adapter: :google_workspace}) do
|
||||
Ecto.UUID.generate()
|
||||
end
|
||||
|
||||
def random_provider_identifier(%Domain.Auth.Provider{adapter: :token}) do
|
||||
Ecto.UUID.generate()
|
||||
end
|
||||
@@ -29,7 +33,9 @@ defmodule Domain.AuthFixtures do
|
||||
Enum.into(attrs, %{
|
||||
name: "provider-#{counter()}",
|
||||
adapter: :email,
|
||||
adapter_config: %{}
|
||||
adapter_config: %{},
|
||||
created_by: :system,
|
||||
provisioner: :manual
|
||||
})
|
||||
end
|
||||
|
||||
@@ -56,11 +62,59 @@ defmodule Domain.AuthFixtures do
|
||||
end)
|
||||
|
||||
attrs =
|
||||
%{adapter: :openid_connect, adapter_config: provider_attrs}
|
||||
%{
|
||||
adapter: :openid_connect,
|
||||
adapter_config: provider_attrs,
|
||||
provisioner: :just_in_time
|
||||
}
|
||||
|> Map.merge(attrs)
|
||||
|> provider_attrs()
|
||||
|
||||
{:ok, provider} = Auth.create_provider(account, attrs)
|
||||
|
||||
provider =
|
||||
provider
|
||||
|> Ecto.Changeset.change(
|
||||
disabled_at: nil,
|
||||
adapter_state: %{}
|
||||
)
|
||||
|> Repo.update!()
|
||||
|
||||
{provider, bypass}
|
||||
end
|
||||
|
||||
def create_google_workspace_provider({bypass, [provider_attrs]}, attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
attrs =
|
||||
%{
|
||||
adapter: :google_workspace,
|
||||
adapter_config: provider_attrs,
|
||||
provisioner: :custom
|
||||
}
|
||||
|> Map.merge(attrs)
|
||||
|> provider_attrs()
|
||||
|
||||
{:ok, provider} = Auth.create_provider(account, attrs)
|
||||
|
||||
provider =
|
||||
provider
|
||||
|> Ecto.Changeset.change(
|
||||
disabled_at: nil,
|
||||
adapter_state: %{
|
||||
"access_token" => "OIDC_ACCESS_TOKEN",
|
||||
"refresh_token" => "OIDC_REFRESH_TOKEN",
|
||||
"expires_at" => DateTime.utc_now() |> DateTime.add(1, :day),
|
||||
"claims" => "openid email profile offline_access"
|
||||
}
|
||||
)
|
||||
|> Repo.update!()
|
||||
|
||||
{provider, bypass}
|
||||
end
|
||||
|
||||
@@ -92,6 +146,15 @@ defmodule Domain.AuthFixtures do
|
||||
provider
|
||||
end
|
||||
|
||||
def disable_provider(provider) do
|
||||
provider = Repo.preload(provider, :account)
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: provider.account)
|
||||
identity = create_identity(account: provider.account, actor: actor)
|
||||
subject = create_subject(identity)
|
||||
{:ok, group} = Auth.disable_provider(provider, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def create_identity(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
@@ -133,7 +196,7 @@ defmodule Domain.AuthFixtures do
|
||||
end)
|
||||
|
||||
{:ok, identity} =
|
||||
Auth.create_identity(actor, provider, provider_identifier, provider_virtual_state)
|
||||
Auth.upsert_identity(actor, provider, provider_identifier, provider_virtual_state)
|
||||
|
||||
if state = Map.get(attrs, :provider_state) do
|
||||
identity
|
||||
@@ -199,8 +262,10 @@ defmodule Domain.AuthFixtures do
|
||||
openid_connect_providers_attrs =
|
||||
discovery_document_url
|
||||
|> openid_connect_providers_attrs()
|
||||
|> Enum.filter(&(&1["id"] in provider_names))
|
||||
|> Enum.map(fn config ->
|
||||
|> Enum.filter(fn {name, _config} ->
|
||||
name in provider_names
|
||||
end)
|
||||
|> Enum.map(fn {_name, config} ->
|
||||
config
|
||||
|> Enum.into(%{})
|
||||
|> Map.merge(overrides)
|
||||
@@ -211,90 +276,66 @@ defmodule Domain.AuthFixtures do
|
||||
|
||||
def openid_connect_provider_attrs(overrides \\ %{}) do
|
||||
Enum.into(overrides, %{
|
||||
"id" => "google",
|
||||
"discovery_document_uri" => "https://firezone.example.com/.well-known/openid-configuration",
|
||||
"client_id" => "google-client-id-#{counter()}",
|
||||
"client_secret" => "google-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/google/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile",
|
||||
"label" => "OIDC Google"
|
||||
"scope" => "openid email profile"
|
||||
})
|
||||
end
|
||||
|
||||
defp openid_connect_providers_attrs(discovery_document_url) do
|
||||
[
|
||||
%{
|
||||
"id" => "google",
|
||||
%{
|
||||
"google" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "google-client-id-#{counter()}",
|
||||
"client_secret" => "google-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/google/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile",
|
||||
"label" => "OIDC Google"
|
||||
"scope" => "openid email profile"
|
||||
},
|
||||
%{
|
||||
"id" => "okta",
|
||||
"okta" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "okta-client-id-#{counter()}",
|
||||
"client_secret" => "okta-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/okta/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile offline_access",
|
||||
"label" => "OIDC Okta"
|
||||
"scope" => "openid email profile offline_access"
|
||||
},
|
||||
%{
|
||||
"id" => "auth0",
|
||||
"auth0" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "auth0-client-id-#{counter()}",
|
||||
"client_secret" => "auth0-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/auth0/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile",
|
||||
"label" => "OIDC Auth0"
|
||||
"scope" => "openid email profile"
|
||||
},
|
||||
%{
|
||||
"id" => "azure",
|
||||
"azure" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "azure-client-id-#{counter()}",
|
||||
"client_secret" => "azure-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/azure/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile offline_access",
|
||||
"label" => "OIDC Azure"
|
||||
"scope" => "openid email profile offline_access"
|
||||
},
|
||||
%{
|
||||
"id" => "onelogin",
|
||||
"onelogin" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "onelogin-client-id-#{counter()}",
|
||||
"client_secret" => "onelogin-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/onelogin/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile offline_access",
|
||||
"label" => "OIDC Onelogin"
|
||||
"scope" => "openid email profile offline_access"
|
||||
},
|
||||
%{
|
||||
"id" => "keycloak",
|
||||
"keycloak" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "keycloak-client-id-#{counter()}",
|
||||
"client_secret" => "keycloak-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/keycloak/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile offline_access",
|
||||
"label" => "OIDC Keycloak"
|
||||
"scope" => "openid email profile offline_access"
|
||||
},
|
||||
%{
|
||||
"id" => "vault",
|
||||
"vault" => %{
|
||||
"discovery_document_uri" => discovery_document_url,
|
||||
"client_id" => "vault-client-id-#{counter()}",
|
||||
"client_secret" => "vault-client-secret",
|
||||
"redirect_uri" => "https://firezone.example.com/auth/oidc/vault/callback/",
|
||||
"response_type" => "code",
|
||||
"scope" => "openid email profile offline_access",
|
||||
"label" => "OIDC Vault"
|
||||
"scope" => "openid email profile offline_access"
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def jwks_attrs do
|
||||
|
||||
@@ -50,17 +50,24 @@ defmodule Domain.GatewaysFixtures do
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
group =
|
||||
{group, attrs} =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Gateways.Group{} = group, _attrs} ->
|
||||
group
|
||||
{group, attrs}
|
||||
|
||||
{group_attrs, _attrs} ->
|
||||
{group_attrs, attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
create_group(group_attrs)
|
||||
{create_group(group_attrs), attrs}
|
||||
end
|
||||
|
||||
Gateways.Token.Changeset.create_changeset(account)
|
||||
{subject, _attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
AuthFixtures.create_subject(identity)
|
||||
end)
|
||||
|
||||
Gateways.Token.Changeset.create_changeset(account, subject)
|
||||
|> Ecto.Changeset.put_change(:group_id, group.id)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
@@ -54,17 +54,24 @@ defmodule Domain.RelaysFixtures do
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
group =
|
||||
{group, attrs} =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Relays.Group{} = group, _attrs} ->
|
||||
group
|
||||
{%Relays.Group{} = group, attrs} ->
|
||||
{group, attrs}
|
||||
|
||||
{group_attrs, _attrs} ->
|
||||
{group_attrs, attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
create_group(group_attrs)
|
||||
{create_group(group_attrs), attrs}
|
||||
end
|
||||
|
||||
Relays.Token.Changeset.create_changeset(account)
|
||||
{subject, _attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account)
|
||||
identity = AuthFixtures.create_identity(account: account, actor: actor)
|
||||
AuthFixtures.create_subject(identity)
|
||||
end)
|
||||
|
||||
Relays.Token.Changeset.create_changeset(account, subject)
|
||||
|> Ecto.Changeset.put_change(:group_id, group.id)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
defmodule Domain.Mocks.GoogleWorkspaceDirectory do
|
||||
alias Domain.Auth.Adapters.GoogleWorkspace
|
||||
|
||||
def override_endpoint_url(url) do
|
||||
config = Domain.Config.fetch_env!(:domain, GoogleWorkspace.APIClient)
|
||||
config = Keyword.put(config, :endpoint, url)
|
||||
Domain.Config.put_env_override(:domain, GoogleWorkspace.APIClient, config)
|
||||
end
|
||||
|
||||
def mock_users_list_endpoint(bypass, users \\ nil) do
|
||||
users_list_endpoint_path = "/admin/directory/v1/users"
|
||||
|
||||
resp =
|
||||
%{
|
||||
"kind" => "admin#directory#users",
|
||||
"users" =>
|
||||
users ||
|
||||
[
|
||||
%{
|
||||
"agreedToTerms" => true,
|
||||
"archived" => false,
|
||||
"changePasswordAtNextLogin" => false,
|
||||
"creationTime" => "2023-06-10T17:32:06.000Z",
|
||||
"customerId" => "CustomerID1",
|
||||
"emails" => [
|
||||
%{"address" => "b@firez.xxx", "primary" => true},
|
||||
%{"address" => "b@ext.firez.xxx"}
|
||||
],
|
||||
"etag" => "\"ET-61Bnx4\"",
|
||||
"id" => "ID4",
|
||||
"includeInGlobalAddressList" => true,
|
||||
"ipWhitelisted" => false,
|
||||
"isAdmin" => false,
|
||||
"isDelegatedAdmin" => false,
|
||||
"isEnforcedIn2Sv" => false,
|
||||
"isEnrolledIn2Sv" => false,
|
||||
"isMailboxSetup" => true,
|
||||
"kind" => "admin#directory#user",
|
||||
"languages" => [%{"languageCode" => "en", "preference" => "preferred"}],
|
||||
"lastLoginTime" => "2023-06-26T13:53:30.000Z",
|
||||
"name" => %{
|
||||
"familyName" => "Manifold",
|
||||
"fullName" => "Brian Manifold",
|
||||
"givenName" => "Brian"
|
||||
},
|
||||
"nonEditableAliases" => ["b@ext.firez.xxx"],
|
||||
"orgUnitPath" => "/Engineering",
|
||||
"organizations" => [
|
||||
%{
|
||||
"customType" => "",
|
||||
"department" => "Engineering",
|
||||
"location" => "",
|
||||
"name" => "Firezone, Inc.",
|
||||
"primary" => true,
|
||||
"title" => "Senior Fullstack Engineer",
|
||||
"type" => "work"
|
||||
}
|
||||
],
|
||||
"phones" => [%{"type" => "mobile", "value" => "(567) 111-2233"}],
|
||||
"primaryEmail" => "b@firez.xxx",
|
||||
"recoveryEmail" => "xxx@xxx.com",
|
||||
"suspended" => false,
|
||||
"thumbnailPhotoEtag" => "\"ET\"",
|
||||
"thumbnailPhotoUrl" =>
|
||||
"https://lh3.google.com/ao/AP2z2aWvm9JM99oCFZ1TVOJgQZlmZdMMYNr7w9G0jZApdTuLHfAueGFb_XzgTvCNRhGw=s96-c"
|
||||
},
|
||||
%{
|
||||
"agreedToTerms" => true,
|
||||
"archived" => false,
|
||||
"changePasswordAtNextLogin" => false,
|
||||
"creationTime" => "2023-05-18T19:10:28.000Z",
|
||||
"customerId" => "CustomerID1",
|
||||
"emails" => [
|
||||
%{"address" => "f@firez.xxx", "primary" => true},
|
||||
%{"address" => "f@ext.firez.xxx"}
|
||||
],
|
||||
"etag" => "\"ET-c\"",
|
||||
"id" => "ID104288977385815201534",
|
||||
"includeInGlobalAddressList" => true,
|
||||
"ipWhitelisted" => false,
|
||||
"isAdmin" => false,
|
||||
"isDelegatedAdmin" => false,
|
||||
"isEnforcedIn2Sv" => false,
|
||||
"isEnrolledIn2Sv" => false,
|
||||
"isMailboxSetup" => true,
|
||||
"kind" => "admin#directory#user",
|
||||
"languages" => [%{"languageCode" => "en", "preference" => "preferred"}],
|
||||
"lastLoginTime" => "2023-06-27T23:12:16.000Z",
|
||||
"name" => %{
|
||||
"familyName" => "Lovebloom",
|
||||
"fullName" => "Francesca Lovebloom",
|
||||
"givenName" => "Francesca"
|
||||
},
|
||||
"nonEditableAliases" => ["f@ext.firez.xxx"],
|
||||
"orgUnitPath" => "/Engineering",
|
||||
"organizations" => [
|
||||
%{
|
||||
"customType" => "",
|
||||
"department" => "Engineering",
|
||||
"location" => "",
|
||||
"name" => "Firezone, Inc.",
|
||||
"primary" => true,
|
||||
"title" => "Senior Systems Engineer",
|
||||
"type" => "work"
|
||||
}
|
||||
],
|
||||
"phones" => [%{"type" => "mobile", "value" => "(567) 111-2233"}],
|
||||
"primaryEmail" => "f@firez.xxx",
|
||||
"recoveryEmail" => "xxx.xxx",
|
||||
"recoveryPhone" => "+15671112323",
|
||||
"suspended" => false
|
||||
},
|
||||
%{
|
||||
"agreedToTerms" => true,
|
||||
"archived" => false,
|
||||
"changePasswordAtNextLogin" => false,
|
||||
"creationTime" => "2022-05-31T19:17:41.000Z",
|
||||
"customerId" => "CustomerID1",
|
||||
"emails" => [
|
||||
%{"address" => "gabriel@firez.xxx", "primary" => true},
|
||||
%{"address" => "gabi@firez.xxx"}
|
||||
],
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "ID2",
|
||||
"includeInGlobalAddressList" => true,
|
||||
"ipWhitelisted" => false,
|
||||
"isAdmin" => false,
|
||||
"isDelegatedAdmin" => false,
|
||||
"isEnforcedIn2Sv" => false,
|
||||
"isEnrolledIn2Sv" => true,
|
||||
"isMailboxSetup" => true,
|
||||
"kind" => "admin#directory#user",
|
||||
"languages" => [%{"languageCode" => "en", "preference" => "preferred"}],
|
||||
"lastLoginTime" => "2023-07-03T17:47:37.000Z",
|
||||
"name" => %{
|
||||
"familyName" => "Steinberg",
|
||||
"fullName" => "Gabriel Steinberg",
|
||||
"givenName" => "Gabriel"
|
||||
},
|
||||
"nonEditableAliases" => ["gabriel@ext.firez.xxx"],
|
||||
"orgUnitPath" => "/Engineering",
|
||||
"primaryEmail" => "gabriel@firez.xxx",
|
||||
"suspended" => false
|
||||
},
|
||||
%{
|
||||
"agreedToTerms" => true,
|
||||
"aliases" => ["jam@firez.xxx"],
|
||||
"archived" => false,
|
||||
"changePasswordAtNextLogin" => false,
|
||||
"creationTime" => "2022-04-19T21:54:21.000Z",
|
||||
"customerId" => "CustomerID1",
|
||||
"emails" => [
|
||||
%{"address" => "j@gmail.com", "type" => "home"},
|
||||
%{"address" => "j@firez.xxx", "primary" => true},
|
||||
%{"address" => "j@firez.xxx"},
|
||||
%{"address" => "j@ext.firez.xxx"}
|
||||
],
|
||||
"etag" => "\"ET-4Z0R5TBJvppLL8\"",
|
||||
"id" => "ID1",
|
||||
"includeInGlobalAddressList" => true,
|
||||
"ipWhitelisted" => false,
|
||||
"isAdmin" => true,
|
||||
"isDelegatedAdmin" => false,
|
||||
"isEnforcedIn2Sv" => false,
|
||||
"isEnrolledIn2Sv" => true,
|
||||
"isMailboxSetup" => true,
|
||||
"kind" => "admin#directory#user",
|
||||
"languages" => [%{"languageCode" => "en", "preference" => "preferred"}],
|
||||
"lastLoginTime" => "2023-07-04T15:08:45.000Z",
|
||||
"name" => %{
|
||||
"familyName" => "Bou Kheir",
|
||||
"fullName" => "Jamil Bou Kheir",
|
||||
"givenName" => "Jamil"
|
||||
},
|
||||
"nonEditableAliases" => ["jamil@ext.firez.xxx"],
|
||||
"orgUnitPath" => "/",
|
||||
"phones" => [],
|
||||
"primaryEmail" => "jamil@firez.xxx",
|
||||
"recoveryEmail" => "xxx.xxx",
|
||||
"recoveryPhone" => "+15671112323",
|
||||
"suspended" => false,
|
||||
"thumbnailPhotoEtag" => "\"ETX\"",
|
||||
"thumbnailPhotoUrl" =>
|
||||
"https://lh3.google.com/ao/AP2z2aWvm9JM99oCFZ1TVOJgQZlmZdMMYNr7w9G0jZApdTuLHfAueGFb_XzgTvCNRhGw=s96-c"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", users_list_endpoint_path, fn conn ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_organization_units_list_endpoint(bypass, org_units \\ nil) do
|
||||
org_units_list_endpoint_path = "/admin/directory/v1/customer/my_customer/orgunits"
|
||||
|
||||
resp =
|
||||
%{
|
||||
"kind" => "admin#directory#org_units",
|
||||
"etag" => "\"FwDC5ZsOozt9qI9yuJfiMqwYO1K-EEG4flsXSov57CY/Y3F7O3B5N0h0C_3Pd3OMifRNUVc\"",
|
||||
"organizationUnits" =>
|
||||
org_units ||
|
||||
[
|
||||
%{
|
||||
"kind" => "admin#directory#orgUnit",
|
||||
"name" => "Engineering",
|
||||
"description" => "Engineering team",
|
||||
"etag" => "\"ET\"",
|
||||
"blockInheritance" => false,
|
||||
"orgUnitId" => "ID1",
|
||||
"orgUnitPath" => "/Engineering",
|
||||
"parentOrgUnitId" => "ID0",
|
||||
"parentOrgUnitPath" => "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", org_units_list_endpoint_path, fn conn ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_groups_list_endpoint(bypass, groups \\ nil) do
|
||||
groups_list_endpoint_path = "/admin/directory/v1/groups"
|
||||
|
||||
resp =
|
||||
%{
|
||||
"kind" => "admin#directory#groups",
|
||||
"etag" => "\"FwDC5ZsOozt9qI9yuJfiMqwYO1K-EEG4flsXSov57CY/Y3F7O3B5N0h0C_3Pd3OMifRNUVc\"",
|
||||
"groups" =>
|
||||
groups ||
|
||||
[
|
||||
%{
|
||||
"kind" => "admin#directory#group",
|
||||
"id" => "ID1",
|
||||
"etag" => "\"ET\"",
|
||||
"email" => "i@fiez.xxx",
|
||||
"name" => "Infrastructure",
|
||||
"directMembersCount" => "5",
|
||||
"description" => "Group to handle infrastructure alerts and management",
|
||||
"adminCreated" => true,
|
||||
"aliases" => [
|
||||
"pnr@firez.one"
|
||||
],
|
||||
"nonEditableAliases" => [
|
||||
"i@ext.fiez.xxx"
|
||||
]
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#group",
|
||||
"id" => "ID2",
|
||||
"etag" => "\"ET\"",
|
||||
"email" => "mktn@fiez.xxx",
|
||||
"name" => "Marketing",
|
||||
"directMembersCount" => "1",
|
||||
"description" => "Firezone Marketing team",
|
||||
"adminCreated" => true,
|
||||
"nonEditableAliases" => [
|
||||
"mktn@ext.fiez.xxx"
|
||||
]
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#group",
|
||||
"id" => "ID9c6y382yitz1j",
|
||||
"etag" => "\"ET\"",
|
||||
"email" => "sec@fiez.xxx",
|
||||
"name" => "Security",
|
||||
"directMembersCount" => "5",
|
||||
"description" => "Security Notifications",
|
||||
"adminCreated" => false,
|
||||
"nonEditableAliases" => [
|
||||
"sec@ext.fiez.xxx"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", groups_list_endpoint_path, fn conn ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_group_members_list_endpoint(bypass, group_id, members \\ nil) do
|
||||
group_members_list_endpoint_path = "/admin/directory/v1/groups/#{group_id}/members"
|
||||
|
||||
resp =
|
||||
%{
|
||||
"kind" => "admin#directory#members",
|
||||
"etag" => "\"XXX\"",
|
||||
"members" =>
|
||||
members ||
|
||||
[
|
||||
%{
|
||||
"kind" => "admin#directory#member",
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "115559319585605830228",
|
||||
"email" => "b@firez.xxx",
|
||||
"role" => "MEMBER",
|
||||
"type" => "USER",
|
||||
"status" => "ACTIVE"
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#member",
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "115559319585605830218",
|
||||
"email" => "j@firez.xxx",
|
||||
"role" => "MEMBER",
|
||||
"type" => "USER",
|
||||
"status" => "ACTIVE"
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#member",
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "115559319585605830518",
|
||||
"email" => "f@firez.xxx",
|
||||
"role" => "MEMBER",
|
||||
"type" => "USER",
|
||||
"status" => "INACTIVE"
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#member",
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "02xcytpi3twf80c",
|
||||
"email" => "eng@firez.xxx",
|
||||
"role" => "MEMBER",
|
||||
"type" => "GROUP",
|
||||
"status" => "ACTIVE"
|
||||
},
|
||||
%{
|
||||
"kind" => "admin#directory#member",
|
||||
"etag" => "\"ET\"",
|
||||
"id" => "02xcytpi16r56td",
|
||||
"email" => "sec@firez.xxx",
|
||||
"role" => "MEMBER",
|
||||
"type" => "GROUP",
|
||||
"status" => "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", group_members_list_endpoint_path, fn conn ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(resp))
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}/")
|
||||
|
||||
bypass
|
||||
end
|
||||
end
|
||||
@@ -20,9 +20,14 @@ import "./event_listeners"
|
||||
let csrfToken = document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
.getAttribute("content")
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
hooks: Hooks,
|
||||
params: { _csrf_token: csrfToken },
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
locale: Intl.NumberFormat().resolvedOptions().locale,
|
||||
},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
||||
@@ -2,24 +2,45 @@ import StatusPage from "../vendor/status_page"
|
||||
|
||||
let Hooks = {}
|
||||
|
||||
// Copy to clipboard
|
||||
|
||||
Hooks.Copy = {
|
||||
mounted() {
|
||||
this.el.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
let text = ev.currentTarget.querySelector("[data-copy]").innerHTML.trim();
|
||||
let cl = ev.currentTarget.querySelector("[data-icon]").classList
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
cl.add("hero-clipboard-document-check");
|
||||
cl.add("text-green-500");
|
||||
cl.remove("hero-clipboard-document");
|
||||
cl.remove("text-gray-500");
|
||||
})
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
// Update status indicator when sidebar is mounted or updated
|
||||
let statusIndicatorClassNames = {
|
||||
none: "bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300",
|
||||
minor:
|
||||
"bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-yellow-900 dark:text-yellow-300",
|
||||
major:
|
||||
"bg-orange-100 text-orange-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-orange-900 dark:text-orange-300",
|
||||
critical:
|
||||
"bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300",
|
||||
none: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
minor: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
|
||||
major: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
|
||||
critical: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
|
||||
}
|
||||
|
||||
const statusUpdater = function () {
|
||||
const self = this
|
||||
const sp = new StatusPage.page({ page: "firezone" })
|
||||
|
||||
sp.summary({
|
||||
success: function (data) {
|
||||
self.el.innerHTML = `<span class="${
|
||||
statusIndicatorClassNames[data.status.indicator]
|
||||
}">${data.status.description}</span>`
|
||||
self.el.innerHTML = `
|
||||
<span class="text-xs font-medium mr-2 px-2.5 py-0.5 rounded ${statusIndicatorClassNames[data.status.indicator]}">
|
||||
${data.status.description}
|
||||
</span>
|
||||
`
|
||||
},
|
||||
error: function (data) {
|
||||
console.error("An error occurred while fetching status page data")
|
||||
@@ -27,6 +48,7 @@ const statusUpdater = function () {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Hooks.StatusPage = {
|
||||
mounted: statusUpdater,
|
||||
updated: statusUpdater,
|
||||
|
||||
@@ -67,6 +67,21 @@ defmodule Web do
|
||||
end
|
||||
end
|
||||
|
||||
def component_library do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
# Core UI components and translation
|
||||
unquote(components())
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def xml do
|
||||
quote do
|
||||
import Phoenix.Template, only: [embed_templates: 1]
|
||||
@@ -97,11 +112,9 @@ defmodule Web do
|
||||
quote do
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
|
||||
# Core UI components and translation
|
||||
import Web.CoreComponents
|
||||
import Web.FormComponents
|
||||
import Web.TableComponents
|
||||
import Web.Gettext
|
||||
unquote(components())
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
@@ -120,6 +133,16 @@ defmodule Web do
|
||||
end
|
||||
end
|
||||
|
||||
def components do
|
||||
quote do
|
||||
import Web.CoreComponents
|
||||
import Web.FormComponents
|
||||
import Web.TableComponents
|
||||
import Web.NavigationComponents
|
||||
import Web.Gettext
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/view/etc.
|
||||
"""
|
||||
|
||||
@@ -5,9 +5,6 @@ defmodule Web.Auth do
|
||||
def signed_in_path(%Auth.Subject{actor: %{type: :account_admin_user}} = subject),
|
||||
do: ~p"/#{subject.account}/dashboard"
|
||||
|
||||
def signed_in_path(%Auth.Subject{actor: %{type: :account_user}} = subject),
|
||||
do: ~p"/#{subject.account}"
|
||||
|
||||
def put_subject_in_session(conn, %Auth.Subject{} = subject) do
|
||||
{:ok, session_token} = Domain.Auth.create_session_token_from_subject(subject)
|
||||
|
||||
@@ -17,6 +14,42 @@ defmodule Web.Auth do
|
||||
|> Plug.Conn.put_session(:live_socket_id, "actors_sessions:#{subject.actor.id}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
This is a wrapper around `Domain.Auth.sign_in/5` that fails authentication and redirects
|
||||
to app install instructions for the users that should not have access to the control plane UI.
|
||||
"""
|
||||
def sign_in(conn, provider, provider_identifier, secret) do
|
||||
case Domain.Auth.sign_in(
|
||||
provider,
|
||||
provider_identifier,
|
||||
secret,
|
||||
conn.assigns.user_agent,
|
||||
conn.remote_ip
|
||||
) do
|
||||
{:ok, %Auth.Subject{actor: %{type: :account_admin_user}} = subject} ->
|
||||
{:ok, subject}
|
||||
|
||||
{:ok, %Auth.Subject{}} ->
|
||||
{:error, :invalid_actor_type}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(conn, provider, payload) do
|
||||
case Domain.Auth.sign_in(provider, payload, conn.assigns.user_agent, conn.remote_ip) do
|
||||
{:ok, %Auth.Subject{actor: %{type: :account_admin_user}} = subject} ->
|
||||
{:ok, subject}
|
||||
|
||||
{:ok, %Auth.Subject{}} ->
|
||||
{:error, :invalid_actor_type}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
@@ -147,12 +180,17 @@ defmodule Web.Auth do
|
||||
* `:redirect_if_user_is_authenticated` - authenticates the user from the session.
|
||||
Redirects to signed_in_path if there's a logged user.
|
||||
|
||||
* `:mount_account` - takes `account_id` from path params and loads the given account
|
||||
into the socket assigns using the `subject` mounted via `:mount_subject`. This is useful
|
||||
because some actions can be performed by superadmin users on behalf of other accounts
|
||||
so we can't really rely on `subject.account` in a lot of places.
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||
the subject:
|
||||
|
||||
defmodule Web.PageLive do
|
||||
defmodule Web.Page do
|
||||
use Web, :live_view
|
||||
|
||||
on_mount {Web.UserAuth, :mount_subject}
|
||||
@@ -169,10 +207,14 @@ defmodule Web.Auth do
|
||||
{:cont, mount_subject(socket, params, session)}
|
||||
end
|
||||
|
||||
def on_mount(:mount_account, params, session, socket) do
|
||||
{:cont, mount_account(socket, params, session)}
|
||||
end
|
||||
|
||||
def on_mount(:ensure_authenticated, params, session, socket) do
|
||||
socket = mount_subject(socket, params, session)
|
||||
|
||||
if socket.assigns.subject do
|
||||
if socket.assigns[:subject] do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
@@ -187,18 +229,18 @@ defmodule Web.Auth do
|
||||
def on_mount(:ensure_account_admin_user_actor, params, session, socket) do
|
||||
socket = mount_subject(socket, params, session)
|
||||
|
||||
if socket.assigns.subject.actor.type == :account_admin_user do
|
||||
if socket.assigns[:subject].actor.type == :account_admin_user do
|
||||
{:cont, socket}
|
||||
else
|
||||
raise Ecto.NoResultsError
|
||||
raise Web.LiveErrors.NotFoundError
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:redirect_if_user_is_authenticated, params, session, socket) do
|
||||
socket = mount_subject(socket, params, session)
|
||||
|
||||
if socket.assigns.subject do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.subject))}
|
||||
if socket.assigns[:subject] do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns[:subject]))}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
@@ -218,4 +260,18 @@ defmodule Web.Auth do
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp mount_account(
|
||||
%{assigns: %{subject: subject}} = socket,
|
||||
%{"account_id" => account_id},
|
||||
_session
|
||||
) do
|
||||
Phoenix.Component.assign_new(socket, :account, fn ->
|
||||
with {:ok, account} <- Domain.Accounts.fetch_account_by_id(account_id, subject) do
|
||||
account
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
5
elixir/apps/web/lib/web/cldr.ex
Normal file
5
elixir/apps/web/lib/web/cldr.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule Web.CLDR do
|
||||
use Cldr,
|
||||
locales: ["en"],
|
||||
providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime]
|
||||
end
|
||||
@@ -11,7 +11,6 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Web, :verified_routes
|
||||
import Web.Gettext
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
def logo(assigns) do
|
||||
@@ -48,15 +47,35 @@ defmodule Web.CoreComponents do
|
||||
|
||||
## Examples
|
||||
|
||||
<.code_block>
|
||||
<.code_block id="foo">
|
||||
The lazy brown fox jumped over the quick dog.
|
||||
</.code_block>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def code_block(assigns) do
|
||||
~H"""
|
||||
<pre class="p-4 overflow-x-auto bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded-lg">
|
||||
<code class="whitespace-pre-line"><%= render_slot(@inner_block) %></code>
|
||||
</pre>
|
||||
<code id={@id} phx-hook="Copy" class={[~w[
|
||||
rounded-lg
|
||||
text-sm text-left sm:text-base text-white
|
||||
inline-flex items-center
|
||||
space-x-4 p-4 pl-6
|
||||
bg-gray-800
|
||||
relative
|
||||
|
||||
], @class]}>
|
||||
<span class="overflow-x-auto" data-copy>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</span>
|
||||
<.icon name="hero-clipboard-document" data-icon class={~w[
|
||||
absolute bottom-1 right-1
|
||||
h-5 w-5
|
||||
transition
|
||||
text-gray-500 group-hover:text-white
|
||||
]} />
|
||||
</code>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -132,13 +151,7 @@ defmodule Web.CoreComponents do
|
||||
|
||||
## Examples
|
||||
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/"},
|
||||
%{label: "Gateways", path: ~p"/gateways"}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.section>
|
||||
<:title>
|
||||
All gateways
|
||||
</:title>
|
||||
@@ -147,72 +160,28 @@ defmodule Web.CoreComponents do
|
||||
Deploy gateway
|
||||
</.add_button>
|
||||
</:actions>
|
||||
</.section_header>
|
||||
</.section>
|
||||
"""
|
||||
slot :breadcrumbs, required: false, doc: "Breadcrumb links"
|
||||
slot :title, required: true, doc: "Title of the section"
|
||||
slot :actions, required: false, doc: "Buttons or other action elements"
|
||||
|
||||
def section_header(assigns) do
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="col-span-full mb-4 xl:mb-2">
|
||||
<%= render_slot(@breadcrumbs) %>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
|
||||
<%= render_slot(@title) %>
|
||||
</h1>
|
||||
<%= render_slot(@actions) %>
|
||||
<div class="inline-flex justify-between items-center space-x-2">
|
||||
<%= render_slot(@actions) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a button group.
|
||||
"""
|
||||
|
||||
slot :first, required: true, doc: "First button"
|
||||
slot :middle, required: false, doc: "Middle button(s)"
|
||||
slot :last, required: true, doc: "Last button"
|
||||
|
||||
def button_group(assigns) do
|
||||
~H"""
|
||||
<div class="inline-flex rounded-md shadow-sm" role="group">
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
|
||||
rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600
|
||||
dark:focus:ring-blue-500 dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(@first) %>
|
||||
</button>
|
||||
<%= for middle <- @middle do %>
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b
|
||||
border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
|
||||
dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(middle) %>
|
||||
</button>
|
||||
<% end %>
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
|
||||
rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
|
||||
dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(@last) %>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a paginator bar.
|
||||
|
||||
@@ -221,7 +190,7 @@ defmodule Web.CoreComponents do
|
||||
<.paginator
|
||||
page={5}
|
||||
total_pages={100}
|
||||
collection_base_path={~p"/users"}/>
|
||||
collection_base_path={~p"/actors"}/>
|
||||
"""
|
||||
attr :page, :integer, required: true, doc: "Current page"
|
||||
attr :total_pages, :integer, required: true, doc: "Total number of pages"
|
||||
@@ -310,117 +279,6 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders navigation breadcrumbs.
|
||||
|
||||
## Examples
|
||||
|
||||
<.breadcrumbs entries={[%{label: "Home", path: ~p"/"}]}/>
|
||||
"""
|
||||
attr :entries, :list, default: [], doc: "List of breadcrumbs"
|
||||
|
||||
def breadcrumbs(assigns) do
|
||||
~H"""
|
||||
<div class="col-span-full mb-4 xl:mb-2">
|
||||
<nav class="flex mb-5" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-2">
|
||||
<li :for={entry <- @entries} class="inline-flex items-center">
|
||||
<%= if entry.label == "Home" do %>
|
||||
<.link
|
||||
navigate={entry.path}
|
||||
class="inline-flex items-center text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<.icon name="hero-home-solid" class="w-4 h-4 mr-2" />
|
||||
<%= entry.label %>
|
||||
</.link>
|
||||
<% else %>
|
||||
<div class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
|
||||
<.link
|
||||
navigate={entry.path}
|
||||
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<%= entry.label %>
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
This is a modal.
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` to configure
|
||||
the closing/cancel event, for example:
|
||||
|
||||
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
||||
This is another modal.
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
slot :inner_block, required: true
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
@@ -504,11 +362,12 @@ defmodule Web.CoreComponents do
|
||||
@doc """
|
||||
Generates a generic error message.
|
||||
"""
|
||||
attr :rest, :global
|
||||
slot :inner_block, required: true
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden" {@rest}>
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
@@ -516,27 +375,30 @@ defmodule Web.CoreComponents do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
Generates an error message for a form where it's not related to a specific field but rather to the form itself,
|
||||
eg. when there is an internal error during API call or one fields not rendered as a form field is invalid.
|
||||
|
||||
### Examples
|
||||
|
||||
<.base_error form={@form} field={:base} />
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
attr :form, :any, required: true, doc: "the form"
|
||||
attr :field, :atom, doc: "field name"
|
||||
attr :rest, :global
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
def base_error(assigns) do
|
||||
assigns = assign_new(assigns, :error, fn -> assigns.form.errors[assigns.field] end)
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
<p
|
||||
:if={@error}
|
||||
data-validation-error-for={"#{@form.id}[#{@field}]"}
|
||||
class="mt-3 mb-3 flex gap-3 text-m leading-6 text-rose-600 phx-no-feedback:hidden"
|
||||
{@rest}
|
||||
>
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<%= translate_error(@error) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -567,30 +429,6 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr :navigate, :any, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Hero Icon](https://heroicons.com).
|
||||
|
||||
@@ -611,10 +449,11 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
<span class={[@name, @class]} {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -686,7 +525,7 @@ defmodule Web.CoreComponents do
|
||||
~H"""
|
||||
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white dark:bg-gray-800 z-20">
|
||||
<.link href="https://firezone.statuspage.io" class="text-xs hover:underline">
|
||||
<span id="status-page-widget" phx-hook="StatusPage" />
|
||||
<span id="status-page-widget" phx-update="ignore" phx-hook="StatusPage" />
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
@@ -713,6 +552,94 @@ defmodule Web.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders datetime field in a format that is suitable for the user's locale.
|
||||
"""
|
||||
attr :datetime, DateTime, required: true
|
||||
attr :format, :atom, default: :short
|
||||
|
||||
def datetime(assigns) do
|
||||
~H"""
|
||||
<span title={@datetime}>
|
||||
<%= Cldr.DateTime.to_string!(@datetime, Web.CLDR, format: @format) %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a string the represents a relative time for a given Datetime
|
||||
from the current time or a given base time
|
||||
"""
|
||||
attr :datetime, DateTime, required: true
|
||||
attr :relative_to, DateTime, required: false
|
||||
|
||||
def relative_datetime(assigns) do
|
||||
assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
|
||||
|
||||
~H"""
|
||||
<span title={@datetime}>
|
||||
<%= Cldr.DateTime.Relative.to_string!(@datetime, Web.CLDR, relative_to: @relative_to) %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders username
|
||||
"""
|
||||
attr :schema, :map, required: true
|
||||
|
||||
def owner(assigns) do
|
||||
case assigns.schema.created_by do
|
||||
:system ->
|
||||
~H"""
|
||||
<span>
|
||||
System
|
||||
</span>
|
||||
"""
|
||||
|
||||
:identity ->
|
||||
~H"""
|
||||
<.link
|
||||
class="text-blue-600 hover:underline"
|
||||
navigate={~p"/#{@schema.account_id}/actors/#{@schema.created_by_identity.actor.id}"}
|
||||
>
|
||||
<%= assigns.schema.created_by_identity.actor.name %>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Helps to pluralize a word based on a cardinal number.
|
||||
|
||||
Cardinal numbers indicate an amount—how many of something we have: one, two, three, four, five.
|
||||
|
||||
Typically for English you want to set `one` and `other` options. The `other` option is used for all
|
||||
other numbers that are not `one`. For example, if you want to pluralize the word "file" you would
|
||||
set `one` to "file" and `other` to "files".
|
||||
"""
|
||||
attr :number, :integer, required: true
|
||||
|
||||
attr :zero, :string, required: false
|
||||
attr :one, :string, required: false
|
||||
attr :two, :string, required: false
|
||||
attr :few, :string, required: false
|
||||
attr :many, :string, required: false
|
||||
attr :other, :string, required: true
|
||||
|
||||
attr :rest, :global
|
||||
|
||||
def cardinal_number(assigns) do
|
||||
opts = Map.take(assigns, [:zero, :one, :two, :few, :many, :other])
|
||||
assigns = Map.put(assigns, :opts, opts)
|
||||
|
||||
~H"""
|
||||
<span data-value={@number} {@rest}>
|
||||
<%= Web.CLDR.Number.Cardinal.pluralize(@number, :en, @opts) %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
@@ -736,30 +663,6 @@ defmodule Web.CoreComponents do
|
||||
)
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.show(to: "##{id}")
|
||||
|> JS.show(
|
||||
to: "##{id}-bg",
|
||||
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> show("##{id}-container")
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
|> JS.focus_first(to: "##{id}-content")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, id) do
|
||||
js
|
||||
|> JS.hide(
|
||||
to: "##{id}-bg",
|
||||
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
|
||||
)
|
||||
|> hide("##{id}-container")
|
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
@@ -787,38 +690,4 @@ defmodule Web.CoreComponents do
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a string the represents a relative time for a given Datetime
|
||||
from the current time or a given base time
|
||||
"""
|
||||
attr :relative, DateTime, required: true
|
||||
attr :relative_to, DateTime, required: false, default: DateTime.utc_now()
|
||||
|
||||
def relative_datetime(assigns) do
|
||||
# Note: This code was written with the intent to be replace by the following in the future:
|
||||
# https://github.com/elixir-cldr/cldr_dates_times/blob/main/lib/cldr/datetime/relative.ex
|
||||
diff = DateTime.diff(assigns[:relative_to], assigns[:relative])
|
||||
|
||||
diff_str =
|
||||
cond do
|
||||
diff <= -24 * 3600 -> "in #{div(-diff, 24 * 3600)}day(s)"
|
||||
diff <= -3600 -> "in #{div(-diff, 3600)}hour(s)"
|
||||
diff <= -60 -> "in #{div(-diff, 60)}minute(s)"
|
||||
diff <= -5 -> "in #{-diff}seconds"
|
||||
diff <= 5 -> "now"
|
||||
diff <= 60 -> "#{diff}seconds ago"
|
||||
diff <= 3600 -> "#{div(diff, 60)} minute(s) ago"
|
||||
diff <= 24 * 3600 -> "#{div(diff, 3600)} hour(s) ago"
|
||||
true -> "#{div(diff, 24 * 3600)} day(s) ago"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, diff_str: diff_str)
|
||||
|
||||
~H"""
|
||||
<span>
|
||||
<%= @diff_str %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,7 +87,7 @@ defmodule Web.FormComponents do
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -104,7 +104,7 @@ defmodule Web.FormComponents do
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -124,7 +124,7 @@ defmodule Web.FormComponents do
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -142,12 +142,13 @@ defmodule Web.FormComponents do
|
||||
class={[
|
||||
"bg-gray-50 p-2.5 block w-full rounded-lg border text-gray-900 focus:ring-primary-600 text-sm",
|
||||
"phx-no-feedback:border-gray-300 phx-no-feedback:focus:border-primary-600",
|
||||
"disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 disabled:shadow-none",
|
||||
"border-gray-300 focus:border-primary-600",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -172,6 +173,49 @@ defmodule Web.FormComponents do
|
||||
|
||||
### Buttons ###
|
||||
|
||||
@doc """
|
||||
Render a button group.
|
||||
"""
|
||||
slot :first, required: true, doc: "First button"
|
||||
slot :middle, required: false, doc: "Middle button(s)"
|
||||
slot :last, required: true, doc: "Last button"
|
||||
|
||||
def button_group(assigns) do
|
||||
~H"""
|
||||
<div class="inline-flex rounded-md shadow-sm" role="group">
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
|
||||
rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600
|
||||
dark:focus:ring-blue-500 dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(@first) %>
|
||||
</button>
|
||||
<%= for middle <- @middle do %>
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b
|
||||
border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
|
||||
dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(middle) %>
|
||||
</button>
|
||||
<% end %>
|
||||
<button type="button" class={~w[
|
||||
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
|
||||
rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
|
||||
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
|
||||
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
|
||||
dark:focus:text-white
|
||||
]}>
|
||||
<%= render_slot(@last) %>
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button.
|
||||
|
||||
@@ -182,7 +226,7 @@ defmodule Web.FormComponents do
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
attr :rest, :global, include: ~w(disabled form name value navigate)
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@@ -239,14 +283,15 @@ defmodule Web.FormComponents do
|
||||
Edit user
|
||||
</.delete_button>
|
||||
"""
|
||||
attr :phx_click, :string, doc: "Action to perform when the button is clicked"
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
def delete_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
|
||||
{@rest}
|
||||
>
|
||||
<!-- XXX: Fix icon for dark mode -->
|
||||
<!-- <.icon name="hero-trash-solid" class="text-red-600 w-5 h-5 mr-1 -ml-1" /> -->
|
||||
@@ -260,7 +305,7 @@ defmodule Web.FormComponents do
|
||||
|
||||
## Examples
|
||||
|
||||
<.add_button navigate={~p"/users/new"}>
|
||||
<.add_button navigate={~p"/actors/new"}>
|
||||
Add user
|
||||
</.add_button>
|
||||
"""
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<ul class="py-1 text-gray-700 dark:text-gray-300" aria-labelledby="dropdown">
|
||||
<li>
|
||||
<a
|
||||
href={~p"/#{@subject.account.id}/sign_out"}
|
||||
href={~p"/#{@account}/sign_out"}
|
||||
class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
Sign out
|
||||
@@ -95,189 +95,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="fixed top-0 left-0 z-40 w-64 h-screen pt-14 pb-8 transition-transform -translate-x-full bg-white border-r border-gray-200 md:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
|
||||
aria-label="Sidenav"
|
||||
id="drawer-navigation"
|
||||
>
|
||||
<div class="overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800">
|
||||
<form action="#" method="GET" class="md:hidden mb-2">
|
||||
<label for="sidebar-search" class="sr-only">Search</label>
|
||||
<div class="relative">
|
||||
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-500 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="sidebar-search"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/dashboard"}
|
||||
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<.icon
|
||||
name="hero-chart-bar-square-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="ml-3">Dashboard</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center p-2 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
aria-controls="dropdown-pages"
|
||||
data-collapse-toggle="dropdown-pages"
|
||||
>
|
||||
<.icon
|
||||
name="hero-user-group-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap">Organization</span>
|
||||
<.icon
|
||||
name="hero-chevron-down-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
||||
/>
|
||||
</button>
|
||||
<ul id="dropdown-pages" class="py-2 space-y-2">
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/users"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Users
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/groups"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Groups
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/devices"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Devices
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/gateways"}
|
||||
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<.icon
|
||||
name="hero-arrow-left-on-rectangle-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap">Gateways</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/resources"}
|
||||
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<.icon
|
||||
name="hero-server-stack-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap">Resources</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/policies"}
|
||||
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
|
||||
>
|
||||
<.icon
|
||||
name="hero-shield-check-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap">Policies</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center p-2 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
aria-controls="dropdown-settings"
|
||||
data-collapse-toggle="dropdown-settings"
|
||||
>
|
||||
<.icon
|
||||
name="hero-cog-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
||||
/>
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap">Settings</span>
|
||||
<.icon
|
||||
name="hero-chevron-down-solid"
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
||||
/>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="py-2 space-y-2">
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/settings/account"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Account
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/settings/identity_providers"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Identity Providers
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/settings/dns"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
DNS
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/settings/api_tokens"}
|
||||
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
API
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<.status_page_widget />
|
||||
</aside>
|
||||
|
||||
<.sidebar>
|
||||
<.sidebar_item navigate={~p"/#{@account}/dashboard"} icon="hero-chart-bar-square-solid">
|
||||
Dashboard
|
||||
</.sidebar_item>
|
||||
|
||||
<.sidebar_item_group id="organization">
|
||||
<:name>Organization</:name>
|
||||
|
||||
<:item navigate={~p"/#{@account}/actors"}>Users</:item>
|
||||
<:item navigate={~p"/#{@account}/groups"}>Groups</:item>
|
||||
<:item navigate={~p"/#{@account}/devices"}>Devices</:item>
|
||||
</.sidebar_item_group>
|
||||
|
||||
<.sidebar_item navigate={~p"/#{@account}/gateways"} icon="hero-arrow-left-on-rectangle-solid">
|
||||
Gateways
|
||||
</.sidebar_item>
|
||||
|
||||
<.sidebar_item navigate={~p"/#{@account}/resources"} icon="hero-server-stack-solid">
|
||||
Resources
|
||||
</.sidebar_item>
|
||||
|
||||
<.sidebar_item navigate={~p"/#{@account}/policies"} icon="hero-shield-check-solid">
|
||||
Policies
|
||||
</.sidebar_item>
|
||||
|
||||
<.sidebar_item_group id="settings">
|
||||
<:name>Settings</:name>
|
||||
|
||||
<:item navigate={~p"/#{@account}/settings/account"}>Account</:item>
|
||||
<:item navigate={~p"/#{@account}/settings/identity_providers"}>Identity Providers</:item>
|
||||
<:item navigate={~p"/#{@account}/settings/dns"}>DNS</:item>
|
||||
<:item navigate={~p"/#{@account}/settings/api_tokens"}>API</:item>
|
||||
</.sidebar_item_group>
|
||||
|
||||
<:bottom>
|
||||
<.status_page_widget />
|
||||
</:bottom>
|
||||
</.sidebar>
|
||||
|
||||
<main class="md:ml-64 h-auto pt-16">
|
||||
<%= @inner_content %>
|
||||
|
||||
177
elixir/apps/web/lib/web/components/navigation_components.ex
Normal file
177
elixir/apps/web/lib/web/components/navigation_components.ex
Normal file
@@ -0,0 +1,177 @@
|
||||
defmodule Web.NavigationComponents do
|
||||
use Phoenix.Component
|
||||
use Web, :verified_routes
|
||||
import Web.CoreComponents
|
||||
|
||||
slot :bottom, required: false
|
||||
|
||||
slot :inner_block,
|
||||
required: true,
|
||||
doc: "The items for the navigation bar should use `sidebar_item` component."
|
||||
|
||||
def sidebar(assigns) do
|
||||
~H"""
|
||||
<aside class={~w[
|
||||
fixed top-0 left-0 z-40
|
||||
w-64 h-screen
|
||||
pt-14 pb-8
|
||||
transition-transform -translate-x-full
|
||||
bg-white border-r border-gray-200
|
||||
md:translate-x-0
|
||||
dark:bg-gray-800 dark:border-gray-700]} aria-label="Sidenav" id="drawer-navigation">
|
||||
<div class="overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800">
|
||||
<ul class="space-y-2">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</ul>
|
||||
</div>
|
||||
<%= render_slot(@bottom) %>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :icon, :string, required: true
|
||||
attr :navigate, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def sidebar_item(assigns) do
|
||||
~H"""
|
||||
<li>
|
||||
<.link navigate={@navigate} class={~w[
|
||||
flex items-center p-2
|
||||
text-base font-medium text-gray-900
|
||||
rounded-lg
|
||||
hover:bg-gray-100
|
||||
dark:text-white dark:hover:bg-gray-700 group]}>
|
||||
<.icon name={@icon} class={~w[
|
||||
w-6 h-6
|
||||
text-gray-500
|
||||
transition duration-75
|
||||
group-hover:text-gray-900
|
||||
dark:text-gray-400 dark:group-hover:text-white]} />
|
||||
<span class="ml-3"><%= render_slot(@inner_block) %></span>
|
||||
</.link>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true, doc: "ID of the nav group container"
|
||||
# attr :icon, :string, required: true
|
||||
# attr :navigate, :string, required: true
|
||||
|
||||
slot :name, required: true
|
||||
|
||||
slot :item, required: true do
|
||||
attr :navigate, :string, required: true
|
||||
end
|
||||
|
||||
def sidebar_item_group(assigns) do
|
||||
~H"""
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class={~w[
|
||||
flex items-center p-2 w-full group rounded-lg
|
||||
text-base font-medium text-gray-900
|
||||
transition duration-75
|
||||
hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700]}
|
||||
aria-controls={"dropdown-#{@id}"}
|
||||
data-collapse-toggle={"dropdown-#{@id}"}
|
||||
>
|
||||
<.icon name="hero-user-group-solid" class={~w[
|
||||
w-6 h-6 text-gray-500
|
||||
transition duration-75
|
||||
group-hover:text-gray-900
|
||||
dark:text-gray-400 dark:group-hover:text-white]} />
|
||||
<span class="flex-1 ml-3 text-left whitespace-nowrap"><%= render_slot(@name) %></span>
|
||||
<.icon name="hero-chevron-down-solid" class={~w[
|
||||
w-6 h-6 text-gray-500
|
||||
transition duration-75
|
||||
group-hover:text-gray-900
|
||||
dark:text-gray-400 dark:group-hover:text-white]} />
|
||||
</button>
|
||||
<ul id={"dropdown-#{@id}"} class="py-2 space-y-2">
|
||||
<li :for={item <- @item}>
|
||||
<.link navigate={item.navigate} class={~w[
|
||||
flex items-center p-2 pl-11 w-full group rounded-lg
|
||||
text-base font-medium text-gray-900
|
||||
transition duration-75
|
||||
hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700]}>
|
||||
<%= render_slot(item) %>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders breadcrumbs section, for elements `<.breadcrumb />` component should be used.
|
||||
"""
|
||||
attr :home_path, :string, required: true, doc: "The path for to the home page for a user."
|
||||
slot :inner_block, required: true, doc: "Breadcrumb entries"
|
||||
|
||||
def breadcrumbs(assigns) do
|
||||
~H"""
|
||||
<nav class="p-4 pb-0" class="flex" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<.link
|
||||
navigate={@home_path}
|
||||
class="inline-flex items-center text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<.icon name="hero-home-solid" class="w-4 h-4 mr-2" /> Home
|
||||
</.link>
|
||||
|
||||
<%= render_slot(@inner_block) %>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a single breadcrumb entry. should be wrapped in <.breadcrumbs> component.
|
||||
"""
|
||||
slot :inner_block, required: true, doc: "The label for the breadcrumb entry."
|
||||
attr :path, :string, required: true, doc: "The path for the breadcrumb entry."
|
||||
|
||||
def breadcrumb(assigns) do
|
||||
~H"""
|
||||
<li class="inline-flex items-center">
|
||||
<div class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
|
||||
<.link
|
||||
navigate={@path}
|
||||
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr :navigate, :any, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -28,19 +28,13 @@ defmodule Web.AuthController do
|
||||
}
|
||||
}) do
|
||||
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
|
||||
{:ok, subject} <-
|
||||
Domain.Auth.sign_in(
|
||||
provider,
|
||||
provider_identifier,
|
||||
secret,
|
||||
conn.assigns.user_agent,
|
||||
conn.remote_ip
|
||||
) do
|
||||
{:ok, subject} <- Web.Auth.sign_in(conn, provider, provider_identifier, secret) do
|
||||
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
|
||||
|
||||
conn
|
||||
|> Web.Auth.renew_session()
|
||||
|> Web.Auth.put_subject_in_session(subject)
|
||||
|> delete_session(:user_return_to)
|
||||
|> redirect(to: redirect_to)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
@@ -49,6 +43,11 @@ defmodule Web.AuthController do
|
||||
|> put_flash(:error, "You can not use this method to sign in.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
|
||||
{:error, :invalid_actor_type} ->
|
||||
conn
|
||||
|> put_flash(:info, "Please use client application to access Firezone.")
|
||||
|> redirect(to: ~p"/#{account_id}")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:userpass_provider_identifier, String.slice(provider_identifier, 0, 160))
|
||||
@@ -90,19 +89,13 @@ defmodule Web.AuthController do
|
||||
"secret" => secret
|
||||
}) do
|
||||
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
|
||||
{:ok, subject} <-
|
||||
Domain.Auth.sign_in(
|
||||
provider,
|
||||
identity_id,
|
||||
secret,
|
||||
conn.assigns.user_agent,
|
||||
conn.remote_ip
|
||||
) do
|
||||
{:ok, subject} <- Web.Auth.sign_in(conn, provider, identity_id, secret) do
|
||||
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
|
||||
|
||||
conn
|
||||
|> Web.Auth.renew_session()
|
||||
|> Web.Auth.put_subject_in_session(subject)
|
||||
|> delete_session(:user_return_to)
|
||||
|> redirect(to: redirect_to)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
@@ -110,6 +103,11 @@ defmodule Web.AuthController do
|
||||
|> put_flash(:error, "You can not use this method to sign in.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
|
||||
{:error, :invalid_actor_type} ->
|
||||
conn
|
||||
|> put_flash(:info, "Please use client application to access Firezone.")
|
||||
|> redirect(to: ~p"/#{account_id}")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "The sign in link is invalid or expired.")
|
||||
@@ -118,7 +116,7 @@ defmodule Web.AuthController do
|
||||
end
|
||||
|
||||
@doc """
|
||||
This controller redirects user to IdP for authentication while persisting
|
||||
This controller redirects user to IdP during sign in for authentication while persisting
|
||||
verification state to prevent various attacks on OpenID Connect.
|
||||
"""
|
||||
def redirect_to_idp(conn, %{"account_id" => account_id, "provider_id" => provider_id}) do
|
||||
@@ -126,15 +124,7 @@ defmodule Web.AuthController do
|
||||
redirect_url =
|
||||
url(~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/handle_callback")
|
||||
|
||||
{:ok, authorization_url, {state, code_verifier}} =
|
||||
OpenIDConnect.authorization_uri(provider, redirect_url)
|
||||
|
||||
key = state_cookie_key(provider.id)
|
||||
value = :erlang.term_to_binary({state, code_verifier})
|
||||
|
||||
conn
|
||||
|> put_resp_cookie(key, value, @state_cookie_options)
|
||||
|> redirect(external: authorization_url)
|
||||
redirect_to_idp(conn, redirect_url, provider)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
@@ -143,8 +133,20 @@ defmodule Web.AuthController do
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_to_idp(%Plug.Conn{} = conn, redirect_url, %Domain.Auth.Provider{} = provider) do
|
||||
{:ok, authorization_url, {state, code_verifier}} =
|
||||
OpenIDConnect.authorization_uri(provider, redirect_url)
|
||||
|
||||
key = state_cookie_key(provider.id)
|
||||
value = :erlang.term_to_binary({state, code_verifier})
|
||||
|
||||
conn
|
||||
|> put_resp_cookie(key, value, @state_cookie_options)
|
||||
|> redirect(external: authorization_url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
This controller handles IdP redirect back to the Firezone.
|
||||
This controller handles IdP redirect back to the Firezone when user signs in.
|
||||
"""
|
||||
def handle_idp_callback(conn, %{
|
||||
"account_id" => account_id,
|
||||
@@ -152,56 +154,56 @@ defmodule Web.AuthController do
|
||||
"state" => state,
|
||||
"code" => code
|
||||
}) do
|
||||
key = state_cookie_key(provider_id)
|
||||
with {:ok, code_verifier, conn} <- verify_state_and_fetch_verifier(conn, provider_id, state) do
|
||||
payload = {
|
||||
url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"),
|
||||
code_verifier,
|
||||
code
|
||||
}
|
||||
|
||||
with {:ok, code_verifier} <- fetch_verified_state(conn, key, state),
|
||||
{:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
|
||||
payload =
|
||||
{
|
||||
url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"),
|
||||
code_verifier,
|
||||
code
|
||||
},
|
||||
{:ok, subject} <-
|
||||
Domain.Auth.sign_in(
|
||||
provider,
|
||||
payload,
|
||||
conn.assigns.user_agent,
|
||||
conn.remote_ip
|
||||
) do
|
||||
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
|
||||
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
|
||||
{:ok, subject} <- Web.Auth.sign_in(conn, provider, payload) do
|
||||
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
|
||||
|
||||
conn
|
||||
|> delete_resp_cookie(key, @state_cookie_options)
|
||||
|> Web.Auth.renew_session()
|
||||
|> Web.Auth.put_subject_in_session(subject)
|
||||
|> redirect(to: redirect_to)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "You can not use this method to sign in.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
|> Web.Auth.renew_session()
|
||||
|> Web.Auth.put_subject_in_session(subject)
|
||||
|> delete_session(:user_return_to)
|
||||
|> redirect(to: redirect_to)
|
||||
else
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "You can not use this method to sign in.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
|
||||
{:error, :invalid_state} ->
|
||||
{:error, :invalid_actor_type} ->
|
||||
conn
|
||||
|> put_flash(:info, "Please use client application to access Firezone.")
|
||||
|> redirect(to: ~p"/#{account_id}")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "You can not authenticate to this account.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
end
|
||||
else
|
||||
{:error, :invalid_state, conn} ->
|
||||
conn
|
||||
|> put_flash(:error, "Your session has expired, please try again.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "You can not authenticate to this account.")
|
||||
|> redirect(to: "/#{account_id}/sign_in")
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_verified_state(conn, key, state) do
|
||||
def verify_state_and_fetch_verifier(conn, provider_id, state) do
|
||||
key = state_cookie_key(provider_id)
|
||||
conn = fetch_cookies(conn, signed: [key])
|
||||
|
||||
with {:ok, encoded_state} <- Map.fetch(conn.cookies, key),
|
||||
{^state, verifier} <- :erlang.binary_to_term(encoded_state, [:safe]) do
|
||||
{:ok, verifier}
|
||||
{persisted_state, persisted_verifier} <- :erlang.binary_to_term(encoded_state, [:safe]),
|
||||
:ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do
|
||||
{:ok, persisted_verifier, delete_resp_cookie(conn, key, @state_cookie_options)}
|
||||
else
|
||||
_ -> {:error, :invalid_state}
|
||||
_ -> {:error, :invalid_state, delete_resp_cookie(conn, key, @state_cookie_options)}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ defmodule Web.Endpoint do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader, reloadable_apps: [:domain, :web]
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :domain
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Web.Auth.EmailLive do
|
||||
defmodule Web.Auth.Email do
|
||||
use Web, {:live_view, layout: {Web.Layouts, :public}}
|
||||
|
||||
def render(assigns) do
|
||||
@@ -1,7 +1,34 @@
|
||||
defmodule Web.Auth.ProvidersLive do
|
||||
defmodule Web.Auth.SignIn do
|
||||
use Web, {:live_view, layout: {Web.Layouts, :public}}
|
||||
alias Domain.{Auth, Accounts}
|
||||
|
||||
def mount(%{"account_id" => account_id}, _session, socket) do
|
||||
with {:ok, account} <- Accounts.fetch_account_by_id(account_id),
|
||||
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
|
||||
providers_by_adapter =
|
||||
providers
|
||||
|> Enum.group_by(fn provider ->
|
||||
parent_adapter =
|
||||
provider
|
||||
|> Auth.fetch_provider_capabilities!()
|
||||
|> Keyword.get(:parent_adapter)
|
||||
|
||||
parent_adapter || provider.adapter
|
||||
end)
|
||||
|> Map.drop([:token])
|
||||
|
||||
{:ok, socket,
|
||||
temporary_assigns: [
|
||||
account: account,
|
||||
providers_by_adapter: providers_by_adapter,
|
||||
page_title: "Sign in"
|
||||
]}
|
||||
else
|
||||
_other ->
|
||||
raise Web.LiveErrors.NotFoundError
|
||||
end
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<section class="bg-gray-50 dark:bg-gray-900">
|
||||
@@ -148,10 +175,16 @@ defmodule Web.Auth.ProvidersLive do
|
||||
|
||||
def openid_connect_button(assigns) do
|
||||
~H"""
|
||||
<a
|
||||
href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider.id}/redirect"}
|
||||
class="w-full inline-flex items-center justify-center py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-gray-900 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
<a href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider.id}/redirect"} class={~w[
|
||||
w-full inline-flex items-center justify-center py-2.5 px-5
|
||||
bg-white rounded-lg
|
||||
text-sm font-medium text-gray-900
|
||||
focus:outline-none
|
||||
border border-gray-200
|
||||
hover:bg-gray-100 hover:text-gray-900
|
||||
focus:z-10 focus:ring-4 focus:ring-gray-200
|
||||
dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400
|
||||
dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700]}>
|
||||
Log in with <%= @provider.name %>
|
||||
</a>
|
||||
"""
|
||||
@@ -160,32 +193,4 @@ defmodule Web.Auth.ProvidersLive do
|
||||
def adapter_enabled?(providers_by_adapter, adapter) do
|
||||
Map.get(providers_by_adapter, adapter, []) != []
|
||||
end
|
||||
|
||||
def mount(%{"account_id" => account_id}, _session, socket) do
|
||||
with {:ok, account} <- Accounts.fetch_account_by_id(account_id),
|
||||
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
|
||||
{:ok, socket,
|
||||
temporary_assigns: [
|
||||
account: account,
|
||||
providers_by_adapter: Enum.group_by(providers, & &1.adapter),
|
||||
page_title: "Sign in"
|
||||
]}
|
||||
else
|
||||
{:ok, []} ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "This account is disabled.")
|
||||
|> redirect(to: ~p"/#{account_id}/")
|
||||
|
||||
{:ok, socket}
|
||||
|
||||
{:error, :not_found} ->
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:error, "Account not found.")
|
||||
|> redirect(to: ~p"/#{account_id}/")
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,14 +1,10 @@
|
||||
defmodule Web.DashboardLive do
|
||||
defmodule Web.Dashboard do
|
||||
use Web, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="col-span-full mb-4 xl:mb-2">
|
||||
<!-- Breadcrumbs -->
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}"}
|
||||
]} />
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,19 +1,16 @@
|
||||
defmodule Web.DevicesLive.Index do
|
||||
defmodule Web.Devices.Index do
|
||||
use Web, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
|
||||
%{label: "Devices", path: ~p"/#{@subject.account}/devices"}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
|
||||
<.breadcrumb path={~p"/#{@account}/devices"}>Devices</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.header>
|
||||
<:title>
|
||||
All devices
|
||||
</:title>
|
||||
</.section_header>
|
||||
</.header>
|
||||
<!-- Devices Table -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
|
||||
@@ -86,7 +83,7 @@ defmodule Web.DevicesLive.Index do
|
||||
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
v1.01 Linux
|
||||
@@ -94,7 +91,7 @@ defmodule Web.DevicesLive.Index do
|
||||
</th>
|
||||
<td class="px-4 py-3">
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
John Doe
|
||||
@@ -149,7 +146,7 @@ defmodule Web.DevicesLive.Index do
|
||||
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
v1.01 iOS
|
||||
@@ -157,7 +154,7 @@ defmodule Web.DevicesLive.Index do
|
||||
</th>
|
||||
<td class="px-4 py-3">
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Steve Johnson
|
||||
@@ -212,7 +209,7 @@ defmodule Web.DevicesLive.Index do
|
||||
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
v1.01 macOS
|
||||
@@ -220,7 +217,7 @@ defmodule Web.DevicesLive.Index do
|
||||
</th>
|
||||
<td class="px-4 py-3">
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
Steinberg, Gabriel
|
||||
@@ -272,7 +269,7 @@ defmodule Web.DevicesLive.Index do
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/devices"} />
|
||||
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/devices"} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -1,23 +1,20 @@
|
||||
defmodule Web.DevicesLive.Show do
|
||||
defmodule Web.Devices.Show do
|
||||
use Web, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
|
||||
%{label: "Devices", path: ~p"/#{@subject.account}/devices"},
|
||||
%{
|
||||
label: "Jamil's Macbook Pro",
|
||||
path: ~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"
|
||||
}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
|
||||
<.breadcrumb path={~p"/#{@account}/devices"}>Devices</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}>
|
||||
Jamil's Macbook Pro
|
||||
</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<.header>
|
||||
<:title>
|
||||
Device details
|
||||
</:title>
|
||||
</.section_header>
|
||||
</.header>
|
||||
<!-- Device Details -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
@@ -42,7 +39,7 @@ defmodule Web.DevicesLive.Show do
|
||||
</th>
|
||||
<td class="px-6 py-4">
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/users/55DDA8CB-69A7-48FC-9048-639021C205A2"}
|
||||
navigate={~p"/#{@account}/actors/55DDA8CB-69A7-48FC-9048-639021C205A2"}
|
||||
class="text-blue-600 hover:underline"
|
||||
>
|
||||
Andrew Dryga
|
||||
@@ -141,7 +138,7 @@ defmodule Web.DevicesLive.Show do
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<.section_header>
|
||||
<.header>
|
||||
<:title>
|
||||
Danger zone
|
||||
</:title>
|
||||
@@ -150,7 +147,7 @@ defmodule Web.DevicesLive.Show do
|
||||
Archive
|
||||
</.delete_button>
|
||||
</:actions>
|
||||
</.section_header>
|
||||
</.header>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Web.GatewaysLive.Edit do
|
||||
defmodule Web.Gateways.Edit do
|
||||
use Web, :live_view
|
||||
|
||||
alias Domain.Gateways
|
||||
@@ -10,25 +10,18 @@ defmodule Web.GatewaysLive.Edit do
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
|
||||
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
|
||||
%{
|
||||
label: "#{@gateway.name_suffix}",
|
||||
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}"
|
||||
},
|
||||
%{
|
||||
label: "Edit",
|
||||
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}/edit"
|
||||
}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}>
|
||||
<%= @gateway.name_suffix %>
|
||||
</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}/edit"}>Edit</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.header>
|
||||
<:title>
|
||||
Editing Gateway <code><%= @gateway.name_suffix %></code>
|
||||
</:title>
|
||||
</.section_header>
|
||||
</.header>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900">
|
||||
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule Web.GatewaysLive.Index do
|
||||
defmodule Web.Gateways.Index do
|
||||
use Web, :live_view
|
||||
|
||||
alias Domain.Gateways
|
||||
@@ -27,22 +27,19 @@ defmodule Web.GatewaysLive.Index do
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
|
||||
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
<.header>
|
||||
<:title>
|
||||
All gateways
|
||||
</:title>
|
||||
<:actions>
|
||||
<.add_button navigate={~p"/#{@subject.account}/gateways/new"}>
|
||||
<.add_button navigate={~p"/#{@account}/gateways/new"}>
|
||||
Add Instance Group
|
||||
</.add_button>
|
||||
</:actions>
|
||||
</.section_header>
|
||||
</.header>
|
||||
<!-- Gateways Table -->
|
||||
<div class="bg-white dark:bg-gray-800 overflow-hidden">
|
||||
<.resource_filter />
|
||||
@@ -50,7 +47,7 @@ defmodule Web.GatewaysLive.Index do
|
||||
<:col label="INSTANCE GROUP"></:col>
|
||||
<:col :let={gateway} label="INSTANCE">
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
|
||||
navigate={~p"/#{@account}/gateways/#{gateway.id}"}
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>
|
||||
<%= gateway.name_suffix %>
|
||||
@@ -76,7 +73,7 @@ defmodule Web.GatewaysLive.Index do
|
||||
</:col>
|
||||
<:action :let={gateway}>
|
||||
<.link
|
||||
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
|
||||
navigate={~p"/#{@account}/gateways/#{gateway.id}"}
|
||||
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
Show
|
||||
@@ -91,7 +88,7 @@ defmodule Web.GatewaysLive.Index do
|
||||
</a>
|
||||
</:action>
|
||||
</.table_with_groups>
|
||||
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/gateways"} />
|
||||
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateways"} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -1,20 +1,18 @@
|
||||
defmodule Web.GatewaysLive.New do
|
||||
defmodule Web.Gateways.New do
|
||||
use Web, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.section_header>
|
||||
<:breadcrumbs>
|
||||
<.breadcrumbs entries={[
|
||||
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
|
||||
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
|
||||
%{label: "Add Gateway", path: ~p"/#{@subject.account}/gateways/new"}
|
||||
]} />
|
||||
</:breadcrumbs>
|
||||
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
|
||||
<.breadcrumb path={~p"/#{@account}/gateways/new"}>Add Gateway</.breadcrumb>
|
||||
</.breadcrumbs>
|
||||
|
||||
<.header>
|
||||
<:title>
|
||||
Add a new Gateway
|
||||
</:title>
|
||||
</.section_header>
|
||||
</.header>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900">
|
||||
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
|
||||
@@ -40,7 +38,7 @@ defmodule Web.GatewaysLive.New do
|
||||
</div>
|
||||
<.tabs id="deployment-instructions">
|
||||
<:tab id="docker-instructions" label="Docker">
|
||||
<.code_block>
|
||||
<.code_block id="code-sample-docker">
|
||||
docker run -d \
|
||||
--name=zigbee2mqtt \
|
||||
--restart=always \
|
||||
@@ -52,7 +50,7 @@ defmodule Web.GatewaysLive.New do
|
||||
</.code_block>
|
||||
</:tab>
|
||||
<:tab id="systemd-instructions" label="Systemd">
|
||||
<.code_block>
|
||||
<.code_block id="code-sample-systemd">
|
||||
[Unit]
|
||||
Description=zigbee2mqtt
|
||||
After=network.target
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user