diff --git a/elixir/apps/domain/lib/domain/accounts.ex b/elixir/apps/domain/lib/domain/accounts.ex index 6f067ef16..b85c1fb37 100644 --- a/elixir/apps/domain/lib/domain/accounts.ex +++ b/elixir/apps/domain/lib/domain/accounts.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/accounts/authorizer.ex b/elixir/apps/domain/lib/domain/accounts/authorizer.ex new file mode 100644 index 000000000..e79b1f66f --- /dev/null +++ b/elixir/apps/domain/lib/domain/accounts/authorizer.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index 7482470ac..37d28e45d 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/actors/actor.ex b/elixir/apps/domain/lib/domain/actors/actor.ex index 180783d23..9e6c3fc1b 100644 --- a/elixir/apps/domain/lib/domain/actors/actor.ex +++ b/elixir/apps/domain/lib/domain/actors/actor.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/actors/actor/changeset.ex b/elixir/apps/domain/lib/domain/actors/actor/changeset.ex index f63837ceb..ece84ff2c 100644 --- a/elixir/apps/domain/lib/domain/actors/actor/changeset.ex +++ b/elixir/apps/domain/lib/domain/actors/actor/changeset.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/actors/authorizer.ex b/elixir/apps/domain/lib/domain/actors/authorizer.ex index 9e64434a9..a8b243d91 100644 --- a/elixir/apps/domain/lib/domain/actors/authorizer.ex +++ b/elixir/apps/domain/lib/domain/actors/authorizer.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group.ex b/elixir/apps/domain/lib/domain/actors/group.ex new file mode 100644 index 000000000..9759d1bee --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group/changeset.ex b/elixir/apps/domain/lib/domain/actors/group/changeset.ex new file mode 100644 index 000000000..75f2ed87d --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/group/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group/query.ex b/elixir/apps/domain/lib/domain/actors/group/query.ex new file mode 100644 index 000000000..09ddc7624 --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/group/query.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/membership.ex b/elixir/apps/domain/lib/domain/actors/membership.ex new file mode 100644 index 000000000..6832df8a7 --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/membership.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/membership/changeset.ex b/elixir/apps/domain/lib/domain/actors/membership/changeset.ex new file mode 100644 index 000000000..6e63df91d --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/membership/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/membership/query.ex b/elixir/apps/domain/lib/domain/actors/membership/query.ex new file mode 100644 index 000000000..a08bda09e --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/membership/query.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index 8e437c0e1..7cc31d81c 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/auth/adapter.ex b/elixir/apps/domain/lib/domain/auth/adapter.ex index 6ce4c99a9..bb131d675 100644 --- a/elixir/apps/domain/lib/domain/auth/adapter.ex +++ b/elixir/apps/domain/lib/domain/auth/adapter.ex @@ -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} diff --git a/elixir/apps/domain/lib/domain/auth/adapters.ex b/elixir/apps/domain/lib/domain/auth/adapters.ex index 1fdc6b509..4d69b2546 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/email.ex b/elixir/apps/domain/lib/domain/auth/adapters/email.ex index 67cbcb34b..070cae730 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/email.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/email.ex @@ -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 ) diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex new file mode 100644 index 000000000..97aab0a78 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex new file mode 100644 index 000000000..23ef3ef68 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex new file mode 100644 index 000000000..b7118c877 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex new file mode 100644 index 000000000..97dd496bb --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/settings.ex @@ -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] diff --git a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex index dbb183c7f..ad9f5105b 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex index a4d903713..bbcb6c41b 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect/settings/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/token.ex b/elixir/apps/domain/lib/domain/auth/adapters/token.ex index 1f146dac3..12c7e361e 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/token.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/token.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex b/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex index 8ee7e98fb..9ef9f46a8 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/userpass.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/identity.ex b/elixir/apps/domain/lib/domain/auth/identity.ex index 820fc4177..82b75b877 100644 --- a/elixir/apps/domain/lib/domain/auth/identity.ex +++ b/elixir/apps/domain/lib/domain/auth/identity.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex index 2fa165680..f3d79dfa5 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/changeset.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/auth/identity/mutator.ex b/elixir/apps/domain/lib/domain/auth/identity/mutator.ex index d3eb0f672..f3d340cd8 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/mutator.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/mutator.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/identity/query.ex b/elixir/apps/domain/lib/domain/auth/identity/query.ex index 410fd2233..3150cf6af 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/query.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/query.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index cae9774de..2f3fe27b3 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex index 59314f066..8516d4afe 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/roles.ex b/elixir/apps/domain/lib/domain/auth/roles.ex index 3968b0fd0..10613a791 100644 --- a/elixir/apps/domain/lib/domain/auth/roles.ex +++ b/elixir/apps/domain/lib/domain/auth/roles.ex @@ -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, diff --git a/elixir/apps/domain/lib/domain/config.ex b/elixir/apps/domain/lib/domain/config.ex index 6f732b763..f774495e2 100644 --- a/elixir/apps/domain/lib/domain/config.ex +++ b/elixir/apps/domain/lib/domain/config.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 1cfec6497..60302ae6e 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -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 ) ############################################## diff --git a/elixir/apps/domain/lib/domain/devices/device/changeset.ex b/elixir/apps/domain/lib/domain/devices/device/changeset.ex index 8a4891214..449ded4be 100644 --- a/elixir/apps/domain/lib/domain/devices/device/changeset.ex +++ b/elixir/apps/domain/lib/domain/devices/device/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways.ex b/elixir/apps/domain/lib/domain/gateways.ex index 3ea1ebe41..935369f62 100644 --- a/elixir/apps/domain/lib/domain/gateways.ex +++ b/elixir/apps/domain/lib/domain/gateways.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex index ed6fe0556..47f20c0b8 100644 --- a/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/gateway/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/group.ex b/elixir/apps/domain/lib/domain/gateways/group.ex index ee2646090..a5abf1ed4 100644 --- a/elixir/apps/domain/lib/domain/gateways/group.ex +++ b/elixir/apps/domain/lib/domain/gateways/group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex index 62598f911..fd960398a 100644 --- a/elixir/apps/domain/lib/domain/gateways/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/group/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/token.ex b/elixir/apps/domain/lib/domain/gateways/token.ex index e0febe49e..edbb33f67 100644 --- a/elixir/apps/domain/lib/domain/gateways/token.ex +++ b/elixir/apps/domain/lib/domain/gateways/token.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/gateways/token/changeset.ex b/elixir/apps/domain/lib/domain/gateways/token/changeset.ex index f8eb04b7d..0a8f02e71 100644 --- a/elixir/apps/domain/lib/domain/gateways/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/gateways/token/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/jobs.ex b/elixir/apps/domain/lib/domain/jobs.ex new file mode 100644 index 000000000..2e05d2465 --- /dev/null +++ b/elixir/apps/domain/lib/domain/jobs.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/jobs/executors/global.ex b/elixir/apps/domain/lib/domain/jobs/executors/global.ex new file mode 100644 index 000000000..bb6a0e4f8 --- /dev/null +++ b/elixir/apps/domain/lib/domain/jobs/executors/global.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/jobs/recurrent.ex b/elixir/apps/domain/lib/domain/jobs/recurrent.ex new file mode 100644 index 000000000..253e0f0c3 --- /dev/null +++ b/elixir/apps/domain/lib/domain/jobs/recurrent.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/relays.ex b/elixir/apps/domain/lib/domain/relays.ex index 7e924553c..828e15fb6 100644 --- a/elixir/apps/domain/lib/domain/relays.ex +++ b/elixir/apps/domain/lib/domain/relays.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/relays/group.ex b/elixir/apps/domain/lib/domain/relays/group.ex index 37cfbc463..f7fc2fe49 100644 --- a/elixir/apps/domain/lib/domain/relays/group.ex +++ b/elixir/apps/domain/lib/domain/relays/group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/relays/group/changeset.ex b/elixir/apps/domain/lib/domain/relays/group/changeset.ex index 1732b19ba..a1f42f008 100644 --- a/elixir/apps/domain/lib/domain/relays/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/group/changeset.ex @@ -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 ) diff --git a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex index c39ebb189..91689d82e 100644 --- a/elixir/apps/domain/lib/domain/relays/relay/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/relay/changeset.ex @@ -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/} diff --git a/elixir/apps/domain/lib/domain/relays/token.ex b/elixir/apps/domain/lib/domain/relays/token.ex index 68b49a241..2d6f072f6 100644 --- a/elixir/apps/domain/lib/domain/relays/token.ex +++ b/elixir/apps/domain/lib/domain/relays/token.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/relays/token/changeset.ex b/elixir/apps/domain/lib/domain/relays/token/changeset.ex index 9ec290eb3..3718979ea 100644 --- a/elixir/apps/domain/lib/domain/relays/token/changeset.ex +++ b/elixir/apps/domain/lib/domain/relays/token/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index f8eacb87a..8905828be 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -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} -> diff --git a/elixir/apps/domain/lib/domain/resources/connection.ex b/elixir/apps/domain/lib/domain/resources/connection.ex index 811685890..fbe1b5ee4 100644 --- a/elixir/apps/domain/lib/domain/resources/connection.ex +++ b/elixir/apps/domain/lib/domain/resources/connection.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex index c11a3d3c1..5019ddab5 100644 --- a/elixir/apps/domain/lib/domain/resources/connection/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/connection/changeset.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/resources/resource.ex b/elixir/apps/domain/lib/domain/resources/resource.ex index 3b963f4d2..e1e75ee3c 100644 --- a/elixir/apps/domain/lib/domain/resources/resource.ex +++ b/elixir/apps/domain/lib/domain/resources/resource.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index 7d5d4bc2e..61ec84c1f 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/types/protocols.ex b/elixir/apps/domain/lib/domain/types/protocols.ex index df6c39112..a87c550ca 100644 --- a/elixir/apps/domain/lib/domain/types/protocols.ex +++ b/elixir/apps/domain/lib/domain/types/protocols.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/validator.ex b/elixir/apps/domain/lib/domain/validator.ex index ef35ba7a5..800e258bd 100644 --- a/elixir/apps/domain/lib/domain/validator.ex +++ b/elixir/apps/domain/lib/domain/validator.ex @@ -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`. """ diff --git a/elixir/apps/domain/priv/repo/migrations/20221224210654_fix_sites_nullable_fields.exs b/elixir/apps/domain/priv/repo/migrations/20221224210654_fix_sites_nullable_fields.exs index cb02b0207..ec8fb4538 100644 --- a/elixir/apps/domain/priv/repo/migrations/20221224210654_fix_sites_nullable_fields.exs +++ b/elixir/apps/domain/priv/repo/migrations/20221224210654_fix_sites_nullable_fields.exs @@ -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}." ) diff --git a/elixir/apps/domain/priv/repo/migrations/20230425101110_create_auth_identities.exs b/elixir/apps/domain/priv/repo/migrations/20230425101110_create_auth_identities.exs index c7327e156..1b8fdb332 100644 --- a/elixir/apps/domain/priv/repo/migrations/20230425101110_create_auth_identities.exs +++ b/elixir/apps/domain/priv/repo/migrations/20230425101110_create_auth_identities.exs @@ -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 ) diff --git a/elixir/apps/domain/priv/repo/migrations/20230627234118_create_actor_groups.exs b/elixir/apps/domain/priv/repo/migrations/20230627234118_create_actor_groups.exs new file mode 100644 index 000000000..fa214da8b --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20230627234118_create_actor_groups.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20230718192810_add_created_by_fields.exs b/elixir/apps/domain/priv/repo/migrations/20230718192810_add_created_by_fields.exs new file mode 100644 index 000000000..d6b08c51b --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20230718192810_add_created_by_fields.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20230720223712_add_auth_providers_google_workspace_fields.exs b/elixir/apps/domain/priv/repo/migrations/20230720223712_add_auth_providers_google_workspace_fields.exs new file mode 100644 index 000000000..943cee716 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20230720223712_add_auth_providers_google_workspace_fields.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index 0529f1ca2..8f3e2422c 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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) diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 508d1c52a..563d10247 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index 1daf8f0cc..086c26cb2 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -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) diff --git a/elixir/apps/domain/test/domain/auth/adapters/email_test.exs b/elixir/apps/domain/test/domain/auth/adapters/email_test.exs index 72fc276f4..050b55ceb 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/email_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/email_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/api_client_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/api_client_test.exs new file mode 100644 index 000000000..9bf8161c4 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/api_client_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs new file mode 100644 index 000000000..caab566a9 --- /dev/null +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs b/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs index 7ee45de28..a58471cd9 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/openid_connect_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/token_test.exs b/elixir/apps/domain/test/domain/auth/adapters/token_test.exs index cb4e38073..804c828cd 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/token_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/token_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs b/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs index 4d3024891..0c951e320 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/userpass_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index d84b5c00f..f36ab78a9 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/gateways_test.exs b/elixir/apps/domain/test/domain/gateways_test.exs index da2294713..90e6ab163 100644 --- a/elixir/apps/domain/test/domain/gateways_test.exs +++ b/elixir/apps/domain/test/domain/gateways_test.exs @@ -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", %{ diff --git a/elixir/apps/domain/test/domain/jobs/executors/global_test.exs b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs new file mode 100644 index 000000000..d25519bd7 --- /dev/null +++ b/elixir/apps/domain/test/domain/jobs/executors/global_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/jobs/recurrent_test.exs b/elixir/apps/domain/test/domain/jobs/recurrent_test.exs new file mode 100644 index 000000000..0fcba2453 --- /dev/null +++ b/elixir/apps/domain/test/domain/jobs/recurrent_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/relays_test.exs b/elixir/apps/domain/test/domain/relays_test.exs index b21fb5925..ccf8bba1f 100644 --- a/elixir/apps/domain/test/domain/relays_test.exs +++ b/elixir/apps/domain/test/domain/relays_test.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index 25db56c75..1dc09ed8c 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -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}, diff --git a/elixir/apps/domain/test/support/fixtures/actors_fixtures.ex b/elixir/apps/domain/test/support/fixtures/actors_fixtures.ex index 3ad4c234f..0c518639e 100644 --- a/elixir/apps/domain/test/support/fixtures/actors_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/actors_fixtures.ex @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/auth_fixtures.ex b/elixir/apps/domain/test/support/fixtures/auth_fixtures.ex index a789a5eac..b0b1b30f4 100644 --- a/elixir/apps/domain/test/support/fixtures/auth_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/auth_fixtures.ex @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex b/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex index f48c7f954..788e5b67e 100644 --- a/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/gateways_fixtures.ex @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex b/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex index 6094ed549..2fdb3ac92 100644 --- a/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex +++ b/elixir/apps/domain/test/support/fixtures/relays_fixtures.ex @@ -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 diff --git a/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex b/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex new file mode 100644 index 000000000..fe79393a5 --- /dev/null +++ b/elixir/apps/domain/test/support/mocks/google_workspace_directory.ex @@ -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 diff --git a/elixir/apps/web/assets/js/app.js b/elixir/apps/web/assets/js/app.js index c1c85c3d6..317140abd 100644 --- a/elixir/apps/web/assets/js/app.js +++ b/elixir/apps/web/assets/js/app.js @@ -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 diff --git a/elixir/apps/web/assets/js/hooks.js b/elixir/apps/web/assets/js/hooks.js index 9567924fc..85678f084 100644 --- a/elixir/apps/web/assets/js/hooks.js +++ b/elixir/apps/web/assets/js/hooks.js @@ -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 = `${data.status.description}` + self.el.innerHTML = ` + + ${data.status.description} + + ` }, 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, diff --git a/elixir/apps/web/lib/web.ex b/elixir/apps/web/lib/web.ex index 06d89a9ed..f17d519a8 100644 --- a/elixir/apps/web/lib/web.ex +++ b/elixir/apps/web/lib/web.ex @@ -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. """ diff --git a/elixir/apps/web/lib/web/auth.ex b/elixir/apps/web/lib/web/auth.ex index 16f178a93..b43436e5f 100644 --- a/elixir/apps/web/lib/web/auth.ex +++ b/elixir/apps/web/lib/web/auth.ex @@ -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 diff --git a/elixir/apps/web/lib/web/cldr.ex b/elixir/apps/web/lib/web/cldr.ex new file mode 100644 index 000000000..4cd82589c --- /dev/null +++ b/elixir/apps/web/lib/web/cldr.ex @@ -0,0 +1,5 @@ +defmodule Web.CLDR do + use Cldr, + locales: ["en"], + providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime] +end diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 27028b32e..d79fa9110 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -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. """ + attr :id, :string, required: true + attr :class, :string, default: "" + slot :inner_block, required: true + def code_block(assigns) do ~H""" -
-      <%= render_slot(@inner_block) %>
-    
+ + + <%= render_slot(@inner_block) %> + + <.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 + ]} /> + """ end @@ -132,13 +151,7 @@ defmodule Web.CoreComponents do ## Examples - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/"}, - %{label: "Gateways", path: ~p"/gateways"} - ]} /> - + <.section> <:title> All gateways @@ -147,72 +160,28 @@ defmodule Web.CoreComponents do Deploy gateway - + """ - 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"""
- <%= render_slot(@breadcrumbs) %>

<%= render_slot(@title) %>

- <%= render_slot(@actions) %> +
+ <%= render_slot(@actions) %> +
""" 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""" -
- - <%= for middle <- @middle do %> - - <% end %> - -
- """ - 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""" -
- -
- """ - end - - @doc """ - Renders a modal. - - ## Examples - - <.modal id="confirm-modal"> - This is a 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. - - - """ - 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""" - """ end @@ -104,7 +104,7 @@ defmodule Web.FormComponents do <%= Phoenix.HTML.Form.options_for_select(@options, @value) %> - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> """ end @@ -124,7 +124,7 @@ defmodule Web.FormComponents do ]} {@rest} ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %> - <.error :for={msg <- @errors}><%= msg %> + <.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> """ 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 :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> """ 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""" +
+ + <%= for middle <- @middle do %> + + <% end %> + +
+ """ + 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 """ - 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""" - - -
  • - <.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" - /> - Gateways - -
  • -
  • - <.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" - /> - Resources - -
  • -
  • - <.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" - /> - Policies - -
  • -
  • - - -
  • - - - <.status_page_widget /> - + +<.sidebar> + <.sidebar_item navigate={~p"/#{@account}/dashboard"} icon="hero-chart-bar-square-solid"> + Dashboard + + + <.sidebar_item_group id="organization"> + <:name>Organization + + <:item navigate={~p"/#{@account}/actors"}>Users + <:item navigate={~p"/#{@account}/groups"}>Groups + <:item navigate={~p"/#{@account}/devices"}>Devices + + + <.sidebar_item navigate={~p"/#{@account}/gateways"} icon="hero-arrow-left-on-rectangle-solid"> + Gateways + + + <.sidebar_item navigate={~p"/#{@account}/resources"} icon="hero-server-stack-solid"> + Resources + + + <.sidebar_item navigate={~p"/#{@account}/policies"} icon="hero-shield-check-solid"> + Policies + + + <.sidebar_item_group id="settings"> + <:name>Settings + + <:item navigate={~p"/#{@account}/settings/account"}>Account + <:item navigate={~p"/#{@account}/settings/identity_providers"}>Identity Providers + <:item navigate={~p"/#{@account}/settings/dns"}>DNS + <:item navigate={~p"/#{@account}/settings/api_tokens"}>API + + + <:bottom> + <.status_page_widget /> + +
    <%= @inner_content %> diff --git a/elixir/apps/web/lib/web/components/navigation_components.ex b/elixir/apps/web/lib/web/components/navigation_components.ex new file mode 100644 index 000000000..bd3de26ea --- /dev/null +++ b/elixir/apps/web/lib/web/components/navigation_components.ex @@ -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""" + + """ + end + + attr :icon, :string, required: true + attr :navigate, :string, required: true + slot :inner_block, required: true + + def sidebar_item(assigns) do + ~H""" +
  • + <.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]} /> + <%= render_slot(@inner_block) %> + +
  • + """ + 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""" +
  • + +
      +
    • + <.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) %> + +
    • +
    +
  • + """ + 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""" + + """ + 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""" +
  • +
    + <.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) %> + +
    +
  • + """ + end + + @doc """ + Renders a back navigation link. + + ## Examples + + <.back navigate={~p"/posts"}>Back to posts + """ + attr :navigate, :any, required: true + slot :inner_block, required: true + + def back(assigns) do + ~H""" +
    + <.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) %> + +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/controllers/auth_controller.ex b/elixir/apps/web/lib/web/controllers/auth_controller.ex index 05bd5e51f..9b34f251f 100644 --- a/elixir/apps/web/lib/web/controllers/auth_controller.ex +++ b/elixir/apps/web/lib/web/controllers/auth_controller.ex @@ -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 diff --git a/elixir/apps/web/lib/web/endpoint.ex b/elixir/apps/web/lib/web/endpoint.ex index 0f28060ee..3f1a7ff86 100644 --- a/elixir/apps/web/lib/web/endpoint.ex +++ b/elixir/apps/web/lib/web/endpoint.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/auth_live/email_live.ex b/elixir/apps/web/lib/web/live/auth/email.ex similarity index 98% rename from elixir/apps/web/lib/web/live/auth_live/email_live.ex rename to elixir/apps/web/lib/web/live/auth/email.ex index f106be564..e98cce727 100644 --- a/elixir/apps/web/lib/web/live/auth_live/email_live.ex +++ b/elixir/apps/web/lib/web/live/auth/email.ex @@ -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 diff --git a/elixir/apps/web/lib/web/live/auth_live/providers_live.ex b/elixir/apps/web/lib/web/live/auth/sign_in.ex similarity index 83% rename from elixir/apps/web/lib/web/live/auth_live/providers_live.ex rename to elixir/apps/web/lib/web/live/auth/sign_in.ex index 824bfa895..a9d15a56a 100644 --- a/elixir/apps/web/lib/web/live/auth_live/providers_live.ex +++ b/elixir/apps/web/lib/web/live/auth/sign_in.ex @@ -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"""
    @@ -148,10 +175,16 @@ defmodule Web.Auth.ProvidersLive do def openid_connect_button(assigns) do ~H""" - + Log in with <%= @provider.name %> """ @@ -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 diff --git a/elixir/apps/web/lib/web/live/dashboard_live.ex b/elixir/apps/web/lib/web/live/dashboard.ex similarity index 66% rename from elixir/apps/web/lib/web/live/dashboard_live.ex rename to elixir/apps/web/lib/web/live/dashboard.ex index d5b62b1b2..cb5b19fa7 100644 --- a/elixir/apps/web/lib/web/live/dashboard_live.ex +++ b/elixir/apps/web/lib/web/live/dashboard.ex @@ -1,14 +1,10 @@ -defmodule Web.DashboardLive do +defmodule Web.Dashboard do use Web, :live_view def render(assigns) do ~H"""
    - - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}"} - ]} />

    Dashboard

    diff --git a/elixir/apps/web/lib/web/live/devices_live/index.ex b/elixir/apps/web/lib/web/live/devices/index.ex similarity index 91% rename from elixir/apps/web/lib/web/live/devices_live/index.ex rename to elixir/apps/web/lib/web/live/devices/index.ex index b7eed25da..66c43ae86 100644 --- a/elixir/apps/web/lib/web/live/devices_live/index.ex +++ b/elixir/apps/web/lib/web/live/devices/index.ex @@ -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 home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/devices"}>Devices + + <.header> <:title> All devices - +
    @@ -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 <.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 <.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 <.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
    - <.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"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/devices_live/show.ex b/elixir/apps/web/lib/web/live/devices/show.ex similarity index 88% rename from elixir/apps/web/lib/web/live/devices_live/show.ex rename to elixir/apps/web/lib/web/live/devices/show.ex index ad8d99f0c..4e8d9d263 100644 --- a/elixir/apps/web/lib/web/live/devices_live/show.ex +++ b/elixir/apps/web/lib/web/live/devices/show.ex @@ -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 home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/devices"}>Devices + <.breadcrumb path={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Jamil's Macbook Pro + + + + <.header> <:title> Device details - +
    @@ -42,7 +39,7 @@ defmodule Web.DevicesLive.Show do
    <.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
    - <.section_header> + <.header> <:title> Danger zone @@ -150,7 +147,7 @@ defmodule Web.DevicesLive.Show do Archive - + """ end end diff --git a/elixir/apps/web/lib/web/live/gateways_live/edit.ex b/elixir/apps/web/lib/web/live/gateways/edit.ex similarity index 71% rename from elixir/apps/web/lib/web/live/gateways_live/edit.ex rename to elixir/apps/web/lib/web/live/gateways/edit.ex index 89b135d1a..4cf098421 100644 --- a/elixir/apps/web/lib/web/live/gateways_live/edit.ex +++ b/elixir/apps/web/lib/web/live/gateways/edit.ex @@ -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 home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}> + <%= @gateway.name_suffix %> + + <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}/edit"}>Edit + + <.header> <:title> Editing Gateway <%= @gateway.name_suffix %> - +
    diff --git a/elixir/apps/web/lib/web/live/gateways_live/index.ex b/elixir/apps/web/lib/web/live/gateways/index.ex similarity index 86% rename from elixir/apps/web/lib/web/live/gateways_live/index.ex rename to elixir/apps/web/lib/web/live/gateways/index.ex index c169ea727..35b493af1 100644 --- a/elixir/apps/web/lib/web/live/gateways_live/index.ex +++ b/elixir/apps/web/lib/web/live/gateways/index.ex @@ -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 home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + + <.header> <:title> All gateways <:actions> - <.add_button navigate={~p"/#{@subject.account}/gateways/new"}> + <.add_button navigate={~p"/#{@account}/gateways/new"}> Add Instance Group - +
    <.resource_filter /> @@ -50,7 +47,7 @@ defmodule Web.GatewaysLive.Index do <:col label="INSTANCE GROUP"> <: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 <: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 - <.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"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/gateways_live/new.ex b/elixir/apps/web/lib/web/live/gateways/new.ex similarity index 84% rename from elixir/apps/web/lib/web/live/gateways_live/new.ex rename to elixir/apps/web/lib/web/live/gateways/new.ex index a06104948..07b061c49 100644 --- a/elixir/apps/web/lib/web/live/gateways_live/new.ex +++ b/elixir/apps/web/lib/web/live/gateways/new.ex @@ -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 home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + <.breadcrumb path={~p"/#{@account}/gateways/new"}>Add Gateway + + + <.header> <:title> Add a new Gateway - +
    @@ -40,7 +38,7 @@ defmodule Web.GatewaysLive.New do
    <.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 <:tab id="systemd-instructions" label="Systemd"> - <.code_block> + <.code_block id="code-sample-systemd"> [Unit] Description=zigbee2mqtt After=network.target diff --git a/elixir/apps/web/lib/web/live/gateways_live/show.ex b/elixir/apps/web/lib/web/live/gateways/show.ex similarity index 85% rename from elixir/apps/web/lib/web/live/gateways_live/show.ex rename to elixir/apps/web/lib/web/live/gateways/show.ex index a802f8261..5e4ab69dd 100644 --- a/elixir/apps/web/lib/web/live/gateways_live/show.ex +++ b/elixir/apps/web/lib/web/live/gateways/show.ex @@ -1,4 +1,4 @@ -defmodule Web.GatewaysLive.Show do +defmodule Web.Gateways.Show do use Web, :live_view alias Domain.Gateways @@ -12,21 +12,17 @@ defmodule Web.GatewaysLive.Show 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}" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/gateways"}>Gateways + <.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}> + <%= @gateway.name_suffix %> + + + <.header> <:title> Gateway: <%= @gateway.name_suffix %> - +
    <.vertical_table> @@ -61,7 +57,7 @@ defmodule Web.GatewaysLive.Show do Last seen <:value> - <.relative_datetime relative={@gateway.last_seen_at} /> + <.relative_datetime datetime={@gateway.last_seen_at} />
    <%= @gateway.last_seen_at %> @@ -108,7 +104,7 @@ defmodule Web.GatewaysLive.Show do <.table id="resources" rows={@resources}> <:col :let={resource} label="NAME"> <.link - navigate={~p"/#{@subject.account}/resources/#{resource.id}"} + navigate={~p"/#{@account}/resources/#{resource.id}"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <%= resource.name %> @@ -120,7 +116,7 @@ defmodule Web.GatewaysLive.Show do
    - <.section_header> + <.header> <:title> Danger zone @@ -129,7 +125,7 @@ defmodule Web.GatewaysLive.Show do Delete Gateway - + """ end end diff --git a/elixir/apps/web/lib/web/live/groups_live/edit.ex b/elixir/apps/web/lib/web/live/groups/edit.ex similarity index 81% rename from elixir/apps/web/lib/web/live/groups_live/edit.ex rename to elixir/apps/web/lib/web/live/groups/edit.ex index 4d5c35d94..288a74f6a 100644 --- a/elixir/apps/web/lib/web/live/groups_live/edit.ex +++ b/elixir/apps/web/lib/web/live/groups/edit.ex @@ -1,27 +1,22 @@ -defmodule Web.GroupsLive.Edit do +defmodule Web.Groups.Edit do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Groups", path: ~p"/#{@subject.account}/groups"}, - %{ - label: "Engineering", - path: ~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - }, - %{ - label: "Edit", - path: ~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/groups"}>Groups + <.breadcrumb path={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Engineering + + <.breadcrumb path={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + Edit + + + <.header> <:title> Editing group Engineering - +
    diff --git a/elixir/apps/web/lib/web/live/groups_live/index.ex b/elixir/apps/web/lib/web/live/groups/index.ex similarity index 92% rename from elixir/apps/web/lib/web/live/groups_live/index.ex rename to elixir/apps/web/lib/web/live/groups/index.ex index 64ccbf109..4449b0aa4 100644 --- a/elixir/apps/web/lib/web/live/groups_live/index.ex +++ b/elixir/apps/web/lib/web/live/groups/index.ex @@ -1,24 +1,22 @@ -defmodule Web.GroupsLive.Index do +defmodule Web.Groups.Index do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Groups", path: ~p"/#{@subject.account}/groups"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/groups"}>Groups + + + <.header> <:title> All groups <:actions> - <.add_button navigate={~p"/#{@subject.account}/groups/new"}> + <.add_button navigate={~p"/#{@account}/groups/new"}> Add a new group - +
    @@ -80,7 +78,7 @@ defmodule Web.GroupsLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > Engineering @@ -119,9 +117,7 @@ defmodule Web.GroupsLive.Index do
  • <.link - navigate={ - ~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Edit @@ -145,7 +141,7 @@ defmodule Web.GroupsLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > DevOps @@ -203,7 +199,7 @@ defmodule Web.GroupsLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > Human Resources @@ -257,7 +253,7 @@ defmodule Web.GroupsLive.Index do
  • - <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/groups"} /> + <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/groups"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/groups_live/new.ex b/elixir/apps/web/lib/web/live/groups/new.ex similarity index 85% rename from elixir/apps/web/lib/web/live/groups_live/new.ex rename to elixir/apps/web/lib/web/live/groups/new.ex index e2a65a64c..f0a58d4a6 100644 --- a/elixir/apps/web/lib/web/live/groups_live/new.ex +++ b/elixir/apps/web/lib/web/live/groups/new.ex @@ -1,20 +1,17 @@ -defmodule Web.GroupsLive.New do +defmodule Web.Groups.New do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Groups", path: ~p"/#{@subject.account}/groups"}, - %{label: "Add Group", path: ~p"/#{@subject.account}/groups/new"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/groups"}>Groups + <.breadcrumb path={~p"/#{@account}/groups/new"}>Add Group + + <.header> <:title> Add a new group - +
    diff --git a/elixir/apps/web/lib/web/live/groups_live/show.ex b/elixir/apps/web/lib/web/live/groups/show.ex similarity index 81% rename from elixir/apps/web/lib/web/live/groups_live/show.ex rename to elixir/apps/web/lib/web/live/groups/show.ex index 8ad59ba55..3da407f74 100644 --- a/elixir/apps/web/lib/web/live/groups_live/show.ex +++ b/elixir/apps/web/lib/web/live/groups/show.ex @@ -1,30 +1,24 @@ -defmodule Web.GroupsLive.Show do +defmodule Web.Groups.Show do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Groups", path: ~p"/#{@subject.account}/groups"}, - %{ - label: "Engineering", - path: ~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/groups"}>Groups + <.breadcrumb path={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Engineering + + + <.header> <:title> Viewing Group Engineering <:actions> - <.edit_button navigate={ - ~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - }> + <.edit_button navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> Edit Group - +
    @@ -51,7 +45,7 @@ defmodule Web.GroupsLive.Show do Created manually by <.link class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/users/BEE2202A-2598-401D-A6C1-8CC09FFB853A"} + navigate={~p"/#{@account}/actors/BEE2202A-2598-401D-A6C1-8CC09FFB853A"} > Jamil Bou Kheir @@ -97,7 +91,7 @@ defmodule Web.GroupsLive.Show do class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.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" > Bou Kheir, Jamil @@ -113,7 +107,7 @@ defmodule Web.GroupsLive.Show do class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.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" > Dryga, Andrew diff --git a/elixir/apps/web/lib/web/live/landing_live.ex b/elixir/apps/web/lib/web/live/landing.ex similarity index 87% rename from elixir/apps/web/lib/web/live/landing_live.ex rename to elixir/apps/web/lib/web/live/landing.ex index 7d2d9496f..a6bfaae17 100644 --- a/elixir/apps/web/lib/web/live/landing_live.ex +++ b/elixir/apps/web/lib/web/live/landing.ex @@ -1,4 +1,4 @@ -defmodule Web.LandingLive do +defmodule Web.Landing do use Web, {:live_view, layout: {Web.Layouts, :public}} def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/policies_live/edit.ex b/elixir/apps/web/lib/web/live/policies/edit.ex similarity index 72% rename from elixir/apps/web/lib/web/live/policies_live/edit.ex rename to elixir/apps/web/lib/web/live/policies/edit.ex index 568c2504a..dd14dee24 100644 --- a/elixir/apps/web/lib/web/live/policies_live/edit.ex +++ b/elixir/apps/web/lib/web/live/policies/edit.ex @@ -1,27 +1,22 @@ -defmodule Web.PoliciesLive.Edit do +defmodule Web.Policies.Edit do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Policies", path: ~p"/#{@subject.account}/policies"}, - %{ - label: "Engineering access to GitLab", - path: ~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - }, - %{ - label: "Edit", - path: ~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/policies"}>Policies + <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Engineering access to GitLab + + <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + Edit + + + <.header> <:title> Edit Policy Engineering access to GitLab - +
    diff --git a/elixir/apps/web/lib/web/live/policies_live/index.ex b/elixir/apps/web/lib/web/live/policies/index.ex similarity index 83% rename from elixir/apps/web/lib/web/live/policies_live/index.ex rename to elixir/apps/web/lib/web/live/policies/index.ex index 6fcb33074..e7e648dda 100644 --- a/elixir/apps/web/lib/web/live/policies_live/index.ex +++ b/elixir/apps/web/lib/web/live/policies/index.ex @@ -1,24 +1,21 @@ -defmodule Web.PoliciesLive.Index do +defmodule Web.Policies.Index do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Policies", path: ~p"/#{@subject.account}/policies"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/policies"}>Policies + + <.header> <:title> All Policies <:actions> - <.add_button navigate={~p"/#{@subject.account}/policies/new"}> + <.add_button navigate={~p"/#{@account}/policies/new"}> Add a new Policy - +
    @@ -79,7 +76,7 @@ defmodule Web.PoliciesLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > Engineering access to Gitlab @@ -88,7 +85,7 @@ defmodule Web.PoliciesLive.Index do
    <.link class="inline-block" - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Engineering @@ -98,7 +95,7 @@ defmodule Web.PoliciesLive.Index do <.link class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > GitLab @@ -115,7 +112,7 @@ defmodule Web.PoliciesLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > IT access to Staging VPC @@ -124,7 +121,7 @@ defmodule Web.PoliciesLive.Index do <.link class="inline-block" - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > IT @@ -134,7 +131,7 @@ defmodule Web.PoliciesLive.Index do <.link class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Staging VPC @@ -151,7 +148,7 @@ defmodule Web.PoliciesLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.link - navigate={~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > Admin access to Jira @@ -160,7 +157,7 @@ defmodule Web.PoliciesLive.Index do <.link class="inline-block" - navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Admin @@ -170,7 +167,7 @@ defmodule Web.PoliciesLive.Index do <.link class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Jira @@ -184,7 +181,7 @@ defmodule Web.PoliciesLive.Index do
    - <.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"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/policies_live/new.ex b/elixir/apps/web/lib/web/live/policies/new.ex similarity index 88% rename from elixir/apps/web/lib/web/live/policies_live/new.ex rename to elixir/apps/web/lib/web/live/policies/new.ex index c68a91ab8..53bf0f22a 100644 --- a/elixir/apps/web/lib/web/live/policies_live/new.ex +++ b/elixir/apps/web/lib/web/live/policies/new.ex @@ -1,20 +1,17 @@ -defmodule Web.PoliciesLive.New do +defmodule Web.Policies.New do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Policies", path: ~p"/#{@subject.account}/policies"}, - %{label: "Add policy", path: ~p"/#{@subject.account}/policies/new"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/policies"}>Policies + <.breadcrumb path={~p"/#{@account}/policies/new"}>Add Policy + + <.header> <:title> Add a new Policy - +
    diff --git a/elixir/apps/web/lib/web/live/policies_live/show.ex b/elixir/apps/web/lib/web/live/policies/show.ex similarity index 81% rename from elixir/apps/web/lib/web/live/policies_live/show.ex rename to elixir/apps/web/lib/web/live/policies/show.ex index 4d49213b6..3613959b1 100644 --- a/elixir/apps/web/lib/web/live/policies_live/show.ex +++ b/elixir/apps/web/lib/web/live/policies/show.ex @@ -1,27 +1,24 @@ -defmodule Web.PoliciesLive.Show do +defmodule Web.Policies.Show do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Policies", path: ~p"/#{@subject.account}/policies"}, - %{label: "Engineering access to GitLab", path: ~p"/#{@subject.account}/policies/new"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/policies"}>Policies + <.breadcrumb path={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Engineering access to GitLab + + + <.header> <:title> Viewing Policy Engineering access to GitLab <:actions> - <.edit_button navigate={ - ~p"/#{@subject.account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - }> + <.edit_button navigate={~p"/#{@account}/policies/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> Edit Policy - +
    @@ -70,7 +67,7 @@ defmodule Web.PoliciesLive.Show do 4/15/22 12:32 PM by <.link class="text-blue-600 hover:underline" - navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Andrew Dryga @@ -112,7 +109,7 @@ defmodule Web.PoliciesLive.Show do
    <.link class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > 2425BD07A38D @@ -120,7 +117,7 @@ defmodule Web.PoliciesLive.Show do <.link class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > <%= "Thomas Eizinger " %> @@ -130,7 +127,7 @@ defmodule Web.PoliciesLive.Show do
    - <.section_header> + <.header> <:title> Danger zone @@ -139,7 +136,7 @@ defmodule Web.PoliciesLive.Show do Delete Policy - + """ end end diff --git a/elixir/apps/web/lib/web/live/resources_live/edit.ex b/elixir/apps/web/lib/web/live/resources/edit.ex similarity index 90% rename from elixir/apps/web/lib/web/live/resources_live/edit.ex rename to elixir/apps/web/lib/web/live/resources/edit.ex index 216f07959..4eccb72d9 100644 --- a/elixir/apps/web/lib/web/live/resources_live/edit.ex +++ b/elixir/apps/web/lib/web/live/resources/edit.ex @@ -1,4 +1,4 @@ -defmodule Web.ResourcesLive.Edit do +defmodule Web.Resources.Edit do use Web, :live_view alias Domain.Gateways @@ -39,25 +39,20 @@ defmodule Web.ResourcesLive.Edit do def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Resources", path: ~p"/#{@subject.account}/resources"}, - %{ - label: "#{@resource.name}", - path: ~p"/#{@subject.account}/resources/#{@resource.id}" - }, - %{ - label: "Edit", - path: ~p"/#{@subject.account}/resources/#{@resource.id}/edit" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/resources"}>Resources + <.breadcrumb path={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + GitLab + + <.breadcrumb path={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + Edit + + + <.header> <:title> Edit Resource - +
    diff --git a/elixir/apps/web/lib/web/live/resources_live/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex similarity index 78% rename from elixir/apps/web/lib/web/live/resources_live/index.ex rename to elixir/apps/web/lib/web/live/resources/index.ex index bfcb3dfa5..6c75725f9 100644 --- a/elixir/apps/web/lib/web/live/resources_live/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -1,4 +1,4 @@ -defmodule Web.ResourcesLive.Index do +defmodule Web.Resources.Index do use Web, :live_view alias Domain.Resources @@ -12,29 +12,26 @@ defmodule Web.ResourcesLive.Index do def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Resources", path: ~p"/#{@subject.account}/resources"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/resources"}>Resources + + <.header> <:title> All Resources <:actions> - <.add_button navigate={~p"/#{@subject.account}/resources/new"}> + <.add_button navigate={~p"/#{@account}/resources/new"}> Add Resource - +
    <.resource_filter /> <.table id="resources" rows={@resources} row_id={&"resource-#{&1.id}"}> <:col :let={resource} label="NAME"> <.link - navigate={~p"/#{@subject.account}/resources/#{resource.id}"} + navigate={~p"/#{@account}/resources/#{resource.id}"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <%= resource.name %> @@ -48,7 +45,7 @@ defmodule Web.ResourcesLive.Index do <:col :let={resource} label="GATEWAY INSTANCE GROUP"> <.link :for={gateway_group <- resource.gateway_groups} - navigate={~p"/#{@subject.account}/gateways"} + navigate={~p"/#{@account}/gateways"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <.badge type="info"> @@ -58,17 +55,17 @@ defmodule Web.ResourcesLive.Index do <:col :let={_resource} label="GROUPS"> TODO - <.link navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + <.link navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> <.badge>Engineering - <.link navigate={~p"/#{@subject.account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + <.link navigate={~p"/#{@account}/groups/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> <.badge>IT <:action :let={resource}> <.link - navigate={~p"/#{@subject.account}/resources/#{resource.id}"} + navigate={~p"/#{@account}/resources/#{resource.id}"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Show @@ -76,7 +73,7 @@ defmodule Web.ResourcesLive.Index do <:action :let={resource}> <.link - navigate={~p"/#{@subject.account}/resources/#{resource.id}/edit"} + navigate={~p"/#{@account}/resources/#{resource.id}/edit"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Edit @@ -91,11 +88,7 @@ defmodule Web.ResourcesLive.Index do - <.paginator - page={3} - total_pages={100} - collection_base_path={~p"/#{@subject.account}/resources"} - /> + <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/resources"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/resources_live/new.ex b/elixir/apps/web/lib/web/live/resources/new.ex similarity index 90% rename from elixir/apps/web/lib/web/live/resources_live/new.ex rename to elixir/apps/web/lib/web/live/resources/new.ex index af11060e5..596326433 100644 --- a/elixir/apps/web/lib/web/live/resources_live/new.ex +++ b/elixir/apps/web/lib/web/live/resources/new.ex @@ -1,4 +1,4 @@ -defmodule Web.ResourcesLive.New do +defmodule Web.Resources.New do use Web, :live_view alias Domain.Gateways @@ -11,18 +11,15 @@ defmodule Web.ResourcesLive.New do def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Resources", path: ~p"/#{@subject.account}/resources"}, - %{label: "Add Resource", path: ~p"/#{@subject.account}/resources/new"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/resources"}>Resources + <.breadcrumb path={~p"/#{@account}/resources/new"}>Add Resource + + <.header> <:title> Add Resource - +
    diff --git a/elixir/apps/web/lib/web/live/resources_live/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex similarity index 81% rename from elixir/apps/web/lib/web/live/resources_live/show.ex rename to elixir/apps/web/lib/web/live/resources/show.ex index 61ae30382..e17004a03 100644 --- a/elixir/apps/web/lib/web/live/resources_live/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -1,4 +1,4 @@ -defmodule Web.ResourcesLive.Show do +defmodule Web.Resources.Show do use Web, :live_view alias Domain.Resources @@ -32,26 +32,22 @@ defmodule Web.ResourcesLive.Show do def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Resources", path: ~p"/#{@subject.account}/resources"}, - %{ - label: "#{@resource.name}", - path: ~p"/#{@subject.account}/resources/#{@resource.id}" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/resources"}>Resources + <.breadcrumb path={~p"/#{@account}/resources/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Jira + + + <.header> <:title> Resource: <%= @resource.name %> <:actions> - <.edit_button navigate={~p"/#{@subject.account}/resources/#{@resource.id}/edit"}> + <.edit_button navigate={~p"/#{@account}/resources/#{@resource.id}/edit"}> Edit Resource - +
    <.vertical_table> @@ -93,7 +89,7 @@ defmodule Web.ResourcesLive.Show do (TODO: <.link class="text-blue-600 hover:underline" - navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} > Andrew Dryga @@ -114,7 +110,7 @@ defmodule Web.ResourcesLive.Show do <.table id="gateway_instance_groups" rows={@resource.gateway_groups}> <:col :let={gateway_group} label="NAME"> <.link - navigate={~p"/#{@subject.account}/gateways"} + navigate={~p"/#{@account}/gateways"} class="font-medium text-blue-600 dark:text-blue-500 hover:underline" > <%= gateway_group.name_prefix %> @@ -126,7 +122,7 @@ defmodule Web.ResourcesLive.Show do
    - <.section_header> + <.header> <:title> Danger zone @@ -135,7 +131,7 @@ defmodule Web.ResourcesLive.Show do Delete Resource - + """ end end diff --git a/elixir/apps/web/lib/web/live/settings_live/account.ex b/elixir/apps/web/lib/web/live/settings/account.ex similarity index 94% rename from elixir/apps/web/lib/web/live/settings_live/account.ex rename to elixir/apps/web/lib/web/live/settings/account.ex index 6d94b097d..5a4791331 100644 --- a/elixir/apps/web/lib/web/live/settings_live/account.ex +++ b/elixir/apps/web/lib/web/live/settings/account.ex @@ -1,19 +1,16 @@ -defmodule Web.SettingsLive.Account do +defmodule Web.Settings.Account do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Account Settings", path: ~p"/#{@subject.account}/settings/account"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/account"}>Account Settings + + <.header> <:title> User profile - +
    @@ -82,11 +79,11 @@ defmodule Web.SettingsLive.Account do
    - <.section_header> + <.header> <:title> License - +

    <.icon name="hero-exclamation-triangle" class="inline-block w-5 h-5 mr-1 text-yellow-500" /> You have 17 days @@ -172,11 +169,11 @@ defmodule Web.SettingsLive.Account do

    - <.section_header> + <.header> <:title> Danger zone - +

    Terminate account

    diff --git a/elixir/apps/web/lib/web/live/settings_live/api_tokens/index.ex b/elixir/apps/web/lib/web/live/settings/api_tokens/index.ex similarity index 68% rename from elixir/apps/web/lib/web/live/settings_live/api_tokens/index.ex rename to elixir/apps/web/lib/web/live/settings/api_tokens/index.ex index 161edd8af..382333660 100644 --- a/elixir/apps/web/lib/web/live/settings_live/api_tokens/index.ex +++ b/elixir/apps/web/lib/web/live/settings/api_tokens/index.ex @@ -1,4 +1,4 @@ -defmodule Web.SettingsLive.ApiTokens.Index do +defmodule Web.Settings.APITokens.Index do use Web, :live_view def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/settings_live/api_tokens/new.ex b/elixir/apps/web/lib/web/live/settings/api_tokens/new.ex similarity index 68% rename from elixir/apps/web/lib/web/live/settings_live/api_tokens/new.ex rename to elixir/apps/web/lib/web/live/settings/api_tokens/new.ex index e4b40d4f0..ea3693edb 100644 --- a/elixir/apps/web/lib/web/live/settings_live/api_tokens/new.ex +++ b/elixir/apps/web/lib/web/live/settings/api_tokens/new.ex @@ -1,4 +1,4 @@ -defmodule Web.SettingsLive.ApiTokens.New do +defmodule Web.Settings.APITokens.New do use Web, :live_view def render(assigns) do diff --git a/elixir/apps/web/lib/web/live/settings_live/dns.ex b/elixir/apps/web/lib/web/live/settings/dns.ex similarity index 91% rename from elixir/apps/web/lib/web/live/settings_live/dns.ex rename to elixir/apps/web/lib/web/live/settings/dns.ex index 931040ca2..9ba66290d 100644 --- a/elixir/apps/web/lib/web/live/settings_live/dns.ex +++ b/elixir/apps/web/lib/web/live/settings/dns.ex @@ -1,19 +1,16 @@ -defmodule Web.SettingsLive.Dns do +defmodule Web.Settings.DNS do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "DNS Settings", path: ~p"/#{@subject.account}/settings/dns"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/dns"}>DNS Settings + + <.header> <:title> DNS - +

    Configure the default resolver used by connected Devices in your Firezone network. Queries for defined Resources will always diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex new file mode 100644 index 000000000..7bb085857 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex @@ -0,0 +1,102 @@ +defmodule Web.Settings.IdentityProviders.Components do + use Web, :component_library + + def status(%{provider: %{deleted_at: deleted_at}} = assigns) when not is_nil(deleted_at) do + ~H""" +

    + + + Deleted + +
    + """ + end + + def status( + %{ + provider: %{ + disabled_at: disabled_at, + adapter_state: %{"status" => "pending_access_token"} + } + } = assigns + ) + when not is_nil(disabled_at) do + ~H""" +
    + + + Pending access token, + + <.link navigate={ + ~p"/#{@provider.account_id}/settings/identity_providers/google_workspace/#{@provider}/redirect" + }> + + + + +
    + """ + end + + def status(%{provider: %{disabled_at: disabled_at}} = assigns) when not is_nil(disabled_at) do + ~H""" +
    + + + Disabled + +
    + """ + end + + def status(assigns) do + ~H""" +
    + + + Active + +
    + """ + end + + def adapter_name(:email), do: "Magic Link" + def adapter_name(:userpass), do: "Username & Password" + def adapter_name(:token), do: "API Access Token" + def adapter_name(:workos), do: "WorkOS" + def adapter_name(:google_workspace), do: "Google Workspace" + def adapter_name(:openid_connect), do: "OpenID Connect" + def adapter_name(:saml), do: "SAML 2.0" + + def view_provider(%{adapter: adapter} = provider) when adapter in [:email, :userpass, :token], + do: ~p"/#{provider.account_id}/settings/identity_providers/system/#{provider}" + + def view_provider(%{adapter: :openid_connect} = provider), + do: ~p"/#{provider.account_id}/settings/identity_providers/openid_connect/#{provider}" + + def view_provider(%{adapter: :google_workspace} = provider), + do: ~p"/#{provider.account_id}/settings/identity_providers/google_workspace/#{provider}" + + def view_provider(%{adapter: :saml} = provider), + do: ~p"/#{provider.account_id}/settings/identity_providers/saml/#{provider}" + + # def edit_provider(%{adapter: adapter} = provider) when adapter in [:email, :userpass, :token], + # do: ~p"/#{provider.account_id}/settings/identity_providers/system/#{provider}/edit" + + # def edit_provider(%{adapter: :openid_connect} = provider), + # do: ~p"/#{provider.account_id}/settings/identity_providers/openid_connect/#{provider}/edit" + + # def edit_provider(%{adapter: :google_workspace} = provider), + # do: ~p"/#{provider.account_id}/settings/identity_providers/google_workspace/#{provider}/edit" + + # def edit_provider(%{adapter: :saml} = provider), + # do: ~p"/#{provider.account_id}/settings/identity_providers/saml/#{provider}/edit" +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex new file mode 100644 index 000000000..be18efdd3 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/components.ex @@ -0,0 +1,108 @@ +defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Components do + use Web, :component_library + + def provider_form(assigns) do + ~H""" +
    + <.form for={@form} phx-change={:change} phx-submit={:submit}> +
    +

    + Step 1. Configure OAuth consent screen +

    + Please make sure that following scopes are added to the OAuth application permissions: + <.code_block + :for={ + {name, scope} <- [ + openid: "openid", + email: "email", + profile: "profile", + orgunit: "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", + group: "https://www.googleapis.com/auth/admin.directory.group.readonly", + user: "https://www.googleapis.com/auth/admin.directory.user.readonly" + ] + } + id={"scope-#{name}"} + class="w-full mb-4 whitespace-nowrap" + > + <%= scope %> + +
    + +
    +

    + Step 2: Create OAuth client +

    + Please make sure that OAuth client has following redirect URL's whitelisted: + <.code_block + :for={ + {type, redirect_url} <- [ + sign_in: url(~p"/#{@account}/sign_in/providers/#{@id}/handle_callback"), + connect: + url( + ~p"/#{@account}/settings/identity_providers/google_workspace/#{@id}/handle_callback" + ) + ] + } + id={"redirect_url-#{type}"} + class="w-full mb-4 whitespace-nowrap" + > + <%= redirect_url %> + +
    + +
    +

    + 3. Configure client +

    + + <.base_error form={@form} field={:base} /> + +
    +
    + <.input + label="Name" + autocomplete="off" + field={@form[:name]} + placeholder="Name this identity provider" + required + /> +

    + A friendly name for this identity provider. This will be displayed to end-users. +

    +
    + + <.inputs_for :let={adapter_config_form} field={@form[:adapter_config]}> +
    + <.input + label="Client ID" + autocomplete="off" + field={adapter_config_form[:client_id]} + required + /> +
    + +
    + <.input + label="Client secret" + autocomplete="off" + field={adapter_config_form[:client_secret]} + required + /> +
    + +
    + +
    + +
    +
    + +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex new file mode 100644 index 000000000..3f6066ae3 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex @@ -0,0 +1,91 @@ +defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do + @doc """ + This controller is similar to Web.AuthController, but it is used to connect IdP account + to the actor and provider rather than logging in using it. + """ + use Web, :controller + alias Domain.Auth.Adapters.GoogleWorkspace + + def redirect_to_idp(conn, %{"provider_id" => provider_id}) do + account = conn.assigns.account + + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do + redirect_url = + url( + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/handle_callback" + ) + + Web.AuthController.redirect_to_idp(conn, redirect_url, provider) + else + {:error, :not_found} -> + conn + |> put_flash(:error, "Provider does not exist.") + |> redirect(to: ~p"/#{account}/settings/identity_providers") + end + end + + def handle_idp_callback(conn, %{ + "provider_id" => provider_id, + "state" => state, + "code" => code + }) do + account = conn.assigns.account + subject = conn.assigns.subject + + with {:ok, code_verifier, conn} <- + Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do + payload = { + url( + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}/handle_callback" + ), + code_verifier, + code + } + + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id), + {:ok, identity} <- + GoogleWorkspace.verify_and_upsert_identity(subject.actor, provider, payload), + attrs = %{adapter_state: identity.provider_state, disabled_at: nil}, + {:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do + redirect(conn, + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + else + {:error, :expired_token} -> + conn + |> put_flash(:error, "The provider returned an expired token, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + + {:error, :invalid_token} -> + conn + |> put_flash(:error, "The provider returned an invalid token, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + + {:error, :not_found} -> + conn + |> put_flash(:error, "Provider does not exist.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + + {:error, _reason} -> + conn + |> put_flash(:error, "You can not authenticate to this account.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + end + else + {:error, :invalid_state, conn} -> + conn + |> put_flash(:error, "Your session has expired, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/google_workspace/#{provider_id}" + ) + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex new file mode 100644 index 000000000..135428f12 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/edit.ex @@ -0,0 +1,68 @@ +defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Edit do + use Web, :live_view + import Web.Settings.IdentityProviders.GoogleWorkspace.Components + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, socket.assigns.subject) do + changeset = Auth.change_provider(provider) + + socket = + assign(socket, + provider: provider, + form: to_form(changeset) + ) + + {:ok, socket} + else + {:error, _reason} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("change", %{"provider" => attrs}, socket) do + changeset = + Auth.change_provider(socket.assigns.provider, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"provider" => attrs}, socket) do + with {:ok, provider} <- + Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) do + socket = + redirect(socket, + to: + ~p"/#{socket.assigns.account}/settings/identity_providers/google_workspace/#{provider}/redirect" + ) + + {:noreply, socket} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/google_workspace/#{@form.data}/edit" + }> + Edit <%= # {@form.data.name} %> + + + <.header> + <:title> + Edit Identity Provider <%= @form.data.name %> + + +
    + <.provider_form account={@account} id={@form.data.id} form={@form} /> +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex new file mode 100644 index 000000000..833802493 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/new.ex @@ -0,0 +1,87 @@ +defmodule Web.Settings.IdentityProviders.GoogleWorkspace.New do + use Web, :live_view + import Web.Settings.IdentityProviders.GoogleWorkspace.Components + alias Domain.Auth + + def mount(_params, _session, socket) do + id = Ecto.UUID.generate() + + changeset = + Auth.new_provider(socket.assigns.account, %{ + adapter: :google_workspace, + adapter_config: %{} + }) + + socket = + assign(socket, + id: id, + form: to_form(changeset) + ) + + {:ok, socket} + end + + def handle_event("change", %{"provider" => attrs}, socket) do + attrs = Map.put(attrs, "adapter", :google_workspace) + + changeset = + Auth.new_provider(socket.assigns.account, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"provider" => attrs}, socket) do + attrs = + attrs + |> Map.put("id", socket.assigns.id) + |> Map.put("adapter", :google_workspace) + # We create provider in a disabled state because we need to write access token for it first + |> Map.put("adapter_state", %{status: :pending_access_token}) + |> Map.put("disabled_at", DateTime.utc_now()) + + with {:ok, provider} <- + Auth.create_provider(socket.assigns.account, attrs, socket.assigns.subject) do + socket = + redirect(socket, + to: + ~p"/#{socket.assigns.account}/settings/identity_providers/google_workspace/#{provider}/redirect" + ) + + {:noreply, socket} + else + {:error, changeset} -> + # Here we can have an insert conflict error, which will be returned without embedded fields information, + # this will crash `.inputs_for` component in the template, so we need to handle it here. + new_changeset = + Auth.new_provider(socket.assigns.account, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(%{new_changeset | errors: changeset.errors}))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}> + Create Identity Provider + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/google_workspace/new"}> + Google Workspace + + + <.header> + <:title> + Add a new Google Workspace Identity Provider + + +
    + <.provider_form account={@account} id={@id} form={@form} /> +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex new file mode 100644 index 000000000..ba86535dc --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex @@ -0,0 +1,161 @@ +defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do + use Web, :live_view + import Web.Settings.IdentityProviders.Components + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- + Auth.fetch_provider_by_id(provider_id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) do + {:ok, assign(socket, provider: provider)} + else + _ -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("delete", _params, socket) do + {:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")} + end + + def handle_event("enable", _params, socket) do + attrs = %{disabled_at: nil} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def handle_event("disable", _params, socket) do + attrs = %{disabled_at: DateTime.utc_now()} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/google_workspace//DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" + }> + <%= @provider.name %> + + + <.header> + <:title> + Viewing Identity Provider <%= @provider.name %> + + <:actions> + <.edit_button navigate={ + ~p"/#{@account}/settings/identity_providers/google_workspace/#{@provider.id}/edit" + }> + Edit Identity Provider + + <.button :if={not is_nil(@provider.disabled_at)} phx-click="enable"> + Enable Identity Provider + + <.button + :if={is_nil(@provider.disabled_at)} + phx-click="disable" + data-confirm="Are you sure want to disable this provider?" + > + Disable Identity Provider + + <.button navigate={ + ~p"/#{@provider.account_id}/settings/identity_providers/google_workspace/#{@provider}/redirect" + }> + Reconnect Identity Provider + + + + + <.header> + <:title>Details + + + <.flash_group flash={@flash} /> + +
    + + + + + + + + + + + + + + + + + + + + + +
    + Name + + <%= @provider.name %> +
    + Status + + <.status provider={@provider} /> +
    + Client ID + + <%= @provider.adapter_config["client_id"] %> +
    + Created + + <.datetime datetime={@provider.inserted_at} /> by <.owner schema={@provider} /> +
    +
    + + <.header> + <:title> + Danger zone + + <:actions> + <.delete_button + data-confirm="Are you sure want to delete this provider along with all related data?" + phx-click="delete" + > + Delete Identity Provider + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex new file mode 100644 index 000000000..91271b3a4 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/index.ex @@ -0,0 +1,157 @@ +defmodule Web.Settings.IdentityProviders.Index do + use Web, :live_view + import Web.Settings.IdentityProviders.Components + alias Domain.{Auth, Actors} + + def mount(_params, _session, socket) do + account = socket.assigns.account + subject = socket.assigns.subject + + with {:ok, providers} <- Auth.list_providers_for_account(account, subject), + {:ok, identities_count_by_provider_id} <- + Auth.fetch_identities_count_grouped_by_provider_id(subject), + {:ok, groups_count_by_provider_id} <- + Actors.fetch_groups_count_grouped_by_provider_id(subject) do + {:ok, socket, + temporary_assigns: [ + identities_count_by_provider_id: identities_count_by_provider_id, + groups_count_by_provider_id: groups_count_by_provider_id, + providers: providers, + page_title: "Identity Providers Settings" + ]} + else + _ -> raise Web.LiveErrors.NotFoundError + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + + <.header> + <:title> + Identity Providers + + + <:actions> + <.add_button navigate={~p"/#{@account}/settings/identity_providers/new"}> + Add Identity Provider + + + +

    + <.link + class="text-blue-600 dark:text-blue-500 hover:underline" + href="https://www.firezone.dev/docs/architecture/sso" + target="_blank" + > + Read more about how SSO works in Firezone. + <.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" /> + +

    + + <.flash_group flash={@flash} /> + +
    + <.table id="providers" rows={@providers} row_id={&"providers-#{&1.id}"}> + <:col :let={provider} label="Name"> + <.link + navigate={view_provider(provider)} + class="font-medium text-blue-600 dark:text-blue-500 hover:underline" + > + <%= provider.name %> + + + <:col :let={provider} label="Type"><%= adapter_name(provider.adapter) %> + <:col :let={provider} label="Status"> + <.status provider={provider} /> + + <:col :let={provider} label="Sync Status"> + <.sync_status + account={@account} + provider={provider} + identities_count_by_provider_id={@identities_count_by_provider_id} + groups_count_by_provider_id={@groups_count_by_provider_id} + /> + + +
    + """ + end + + def sync_status(%{provider: %{provisioner: :custom}} = assigns) do + ~H""" +
    + + + Synced + <.link + navigate={~p"/#{@account}/actors?provider_id=#{@provider.id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" + > + <% identities_count_by_provider_id = @identities_count_by_provider_id[@provider.id] || 0 %> + <%= identities_count_by_provider_id %> + <.cardinal_number + number={identities_count_by_provider_id} + one="identity" + other="identities" + /> + + and + <.link + navigate={~p"/#{@account}/groups?provider_id=#{@provider.id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" + > + <% groups_count_by_provider_id = @groups_count_by_provider_id[@provider.id] || 0 %> + <%= groups_count_by_provider_id %> + <.cardinal_number number={groups_count_by_provider_id} one="group" other="groups" /> + + + <.relative_datetime datetime={@provider.last_synced_at} /> + +
    +
    + + + Never synced + +
    + """ + end + + def sync_status(%{provider: %{provisioner: provisioner}} = assigns) + when provisioner in [:just_in_time, :manual] do + ~H""" +
    + + + Created + <.link + navigate={~p"/#{@account}/actors?provider_id=#{@provider.id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" + > + <% identities_count_by_provider_id = @identities_count_by_provider_id[@provider.id] || 0 %> + <%= identities_count_by_provider_id %> + <.cardinal_number + number={identities_count_by_provider_id} + one="identity" + other="identities" + /> + + and + <.link + navigate={~p"/#{@account}/groups?provider_id=#{@provider.id}"} + class="text-blue-600 dark:text-blue-500 hover:underline" + > + <% groups_count_by_provider_id = @groups_count_by_provider_id[@provider.id] || 0 %> + <%= groups_count_by_provider_id %> + <.cardinal_number number={groups_count_by_provider_id} one="group" other="groups" /> + + +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex new file mode 100644 index 000000000..7a3079d54 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/new.ex @@ -0,0 +1,147 @@ +defmodule Web.Settings.IdentityProviders.New do + use Web, :live_view + alias Domain.Auth + + def mount(_params, _session, socket) do + {:ok, adapters} = Auth.list_provider_adapters() + + socket = + socket + |> assign(:form, %{}) + + {:ok, socket, + temporary_assigns: [ + adapters: adapters + ]} + end + + def handle_event("submit", %{"next" => next}, socket) do + {:noreply, push_navigate(socket, to: next)} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}> + Create Identity Provider + + + <.header> + <:title> + Add a new Identity Provider + + +
    +
    +

    Choose type

    + <.form id="identity-provider-type-form" for={@form} phx-submit="submit"> +
    +
    + Identity Provider Type + + <.adapter :for={{adapter, _module} <- @adapters} adapter={adapter} account={@account} /> +
    +
    +
    + +
    + +
    +
    + """ + end + + def adapter(%{adapter: :workos} = assigns) do + ~H""" + <.adapter_item + adapter={@adapter} + account={@account} + name="WorkOS" + description="Authenticate users and synchronize users and groups using SCIM and 12+ other directory services." + /> + """ + end + + def adapter(%{adapter: :google_workspace} = assigns) do + ~H""" + <.adapter_item + adapter={@adapter} + account={@account} + name="Google Workspace" + description="Authenticate users and synchronize users and groups with preconfigured Google Workspace connector." + /> + """ + end + + def adapter(%{adapter: :openid_connect} = assigns) do + ~H""" + <.adapter_item + adapter={@adapter} + account={@account} + name="OpenID Connect" + description="Authenticate users with a generic OpenID Connect adapter and synchronize users and groups with just-in-time provisioning." + /> + """ + end + + def adapter(%{adapter: :saml} = assigns) do + ~H""" + <.adapter_item + adapter={@adapter} + account={@account} + name="SAML 2.0" + description="Authenticate users with a custom SAML 2.0 adapter and synchronize users and groups with SCIM 2.0." + /> + """ + end + + def adapter_item(assigns) do + ~H""" +
    +
    + + +
    +

    + <%= @description %> +

    +
    + """ + end + + def next_step_path(:openid_connect, account) do + ~p"/#{account}/settings/identity_providers/openid_connect/new" + end + + def next_step_path(:google_workspace, account) do + ~p"/#{account}/settings/identity_providers/google_workspace/new" + end + + # def next_step_path(:workos, account) do + # ~p"/#{account}/settings/identity_providers/workos/new" + # end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex new file mode 100644 index 000000000..c31ac8f86 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/components.ex @@ -0,0 +1,127 @@ +defmodule Web.Settings.IdentityProviders.OpenIDConnect.Components do + use Web, :component_library + + def provider_form(assigns) do + ~H""" +
    + <.form for={@form} phx-change={:change} phx-submit={:submit}> +
    +

    + Step 1. Create OAuth app +

    + Please make sure that following scopes are added to the OAuth application has following access scopes: + <.code_block + :for={scope <- [:openid, :email, :profile]} + id={"scope-#{scope}"} + class="w-full mb-4 whitespace-nowrap" + > + <%= scope %> + + Please make sure that OAuth client has following redirect URL's whitelisted: + <.code_block + :for={ + {type, redirect_url} <- [ + sign_in: url(~p"/#{@account}/sign_in/providers/#{@id}/handle_callback"), + connect: + url( + ~p"/#{@account}/settings/identity_providers/google_workspace/#{@id}/handle_callback" + ) + ] + } + id={"redirect_url-#{type}"} + class="w-full mb-4 whitespace-nowrap" + > + <%= redirect_url %> + +
    + +
    +

    + 2. Configure client +

    + + <.base_error form={@form} field={:base} /> + +
    +
    + <.input + label="Name" + autocomplete="off" + field={@form[:name]} + placeholder="Name this identity provider" + required + /> +

    + A friendly name for this identity provider. This will be displayed to end-users. +

    +
    + + <.inputs_for :let={adapter_config_form} field={@form[:adapter_config]}> +
    + <.input + label="Response Type" + field={adapter_config_form[:response_type]} + placeholder="code" + value="code" + disabled + /> +

    + Firezone currently only supports code flows. +

    +
    + +
    + <.input + label="Scopes" + autocomplete="off" + field={adapter_config_form[:scope]} + placeholder="OpenID Connect scopes to request" + required + /> +

    + A space-delimited list of scopes to request from your identity provider. In most cases you shouldn't need to change this. +

    +
    + +
    + <.input + label="Client ID" + autocomplete="off" + field={adapter_config_form[:client_id]} + required + /> +
    + +
    + <.input + label="Client secret" + autocomplete="off" + field={adapter_config_form[:client_secret]} + required + /> +
    + +
    + <.input + label="Discovery URL" + field={adapter_config_form[:discovery_document_uri]} + required + /> +
    + +
    + +
    + +
    +
    + +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex new file mode 100644 index 000000000..293327436 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/connect.ex @@ -0,0 +1,89 @@ +defmodule Web.Settings.IdentityProviders.OpenIDConnect.Connect do + @doc """ + This controller is similar to Web.AuthController, but it is used to connect IdP account + to the actor and provider rather than logging in using it. + """ + use Web, :controller + alias Domain.Auth.Adapters.OpenIDConnect + + def redirect_to_idp(conn, %{"provider_id" => provider_id}) do + account = conn.assigns.account + + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id) do + redirect_url = + url( + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider.id}/handle_callback" + ) + + Web.AuthController.redirect_to_idp(conn, redirect_url, provider) + else + {:error, :not_found} -> + conn + |> put_flash(:error, "Provider does not exist.") + |> redirect(to: ~p"/#{account}/settings/identity_providers") + end + end + + def handle_idp_callback(conn, %{ + "provider_id" => provider_id, + "state" => state, + "code" => code + }) do + account = conn.assigns.account + subject = conn.assigns.subject + + with {:ok, code_verifier, conn} <- + Web.AuthController.verify_state_and_fetch_verifier(conn, provider_id, state) do + payload = { + url( + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}/handle_callback" + ), + code_verifier, + code + } + + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id), + {:ok, _identity} <- + OpenIDConnect.verify_and_upsert_identity(subject.actor, provider, payload), + attrs = %{adapter_state: %{status: :connected}, disabled_at: nil}, + {:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do + redirect(conn, + to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}" + ) + else + {:error, :expired_token} -> + conn + |> put_flash(:error, "The provider returned an expired token, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}" + ) + + {:error, :invalid_token} -> + conn + |> put_flash(:error, "The provider returned an invalid token, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}" + ) + + {:error, :not_found} -> + conn + |> put_flash(:error, "Provider is disabled or does not exist.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}" + ) + + {:error, _reason} -> + conn + |> put_flash(:error, "Failed to connect a provider, please try again.") + |> redirect( + to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}" + ) + end + else + {:error, :invalid_state, conn} -> + conn + |> put_flash(:error, "Your session has expired, please try again.") + |> redirect(to: ~p"/#{account}/settings/identity_providers/openid_connect/#{provider_id}") + end + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex new file mode 100644 index 000000000..24a61818d --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/edit.ex @@ -0,0 +1,68 @@ +defmodule Web.Settings.IdentityProviders.OpenIDConnect.Edit do + use Web, :live_view + import Web.Settings.IdentityProviders.OpenIDConnect.Components + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- Domain.Auth.fetch_provider_by_id(provider_id, socket.assigns.subject) do + changeset = Auth.change_provider(provider) + + socket = + assign(socket, + provider: provider, + form: to_form(changeset) + ) + + {:ok, socket} + else + {:error, _reason} -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("change", %{"provider" => attrs}, socket) do + changeset = + Auth.change_provider(socket.assigns.provider, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"provider" => attrs}, socket) do + with {:ok, provider} <- + Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) do + socket = + redirect(socket, + to: + ~p"/#{socket.assigns.account}/settings/identity_providers/openid_connect/#{provider}/redirect" + ) + + {:noreply, socket} + else + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/openid_connect/#{@form.data}/edit" + }> + Edit <%= # {@form.data.name} %> + + + <.header> + <:title> + Edit Identity Provider <%= @form.data.name %> + + +
    + <.provider_form account={@account} id={@form.data.id} form={@form} /> +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex new file mode 100644 index 000000000..578e74fc6 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/new.ex @@ -0,0 +1,88 @@ +defmodule Web.Settings.IdentityProviders.OpenIDConnect.New do + use Web, :live_view + import Web.Settings.IdentityProviders.OpenIDConnect.Components + alias Domain.Auth + + def mount(_params, _session, socket) do + id = Ecto.UUID.generate() + account = socket.assigns.account + + changeset = + Auth.new_provider(account, %{ + adapter: :openid_connect, + adapter_config: %{} + }) + + socket = + assign(socket, + id: id, + form: to_form(changeset) + ) + + {:ok, socket} + end + + def handle_event("change", %{"provider" => attrs}, socket) do + attrs = Map.put(attrs, "adapter", :openid_connect) + + changeset = + Auth.new_provider(socket.assigns.account, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(changeset))} + end + + def handle_event("submit", %{"provider" => attrs}, socket) do + attrs = + attrs + |> Map.put("id", socket.assigns.id) + |> Map.put("adapter", :openid_connect) + # We create provider in a disabled state because we need to write access token for it first + |> Map.put("adapter_state", %{status: :pending_access_token}) + |> Map.put("disabled_at", DateTime.utc_now()) + + with {:ok, provider} <- + Auth.create_provider(socket.assigns.account, attrs, socket.assigns.subject) do + socket = + redirect(socket, + to: + ~p"/#{socket.assigns.account}/settings/identity_providers/openid_connect/#{provider}/redirect" + ) + + {:noreply, socket} + else + {:error, changeset} -> + # Here we can have an insert conflict error, which will be returned without embedded fields information, + # this will crash `.inputs_for` component in the template, so we need to handle it here. + new_changeset = + Auth.new_provider(socket.assigns.account, attrs) + |> Map.put(:action, :insert) + + {:noreply, assign(socket, form: to_form(%{new_changeset | errors: changeset.errors}))} + end + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}> + Create Identity Provider + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/openid_connect/new"}> + OpenID Connect + + + <.header> + <:title> + Add a new OpenID Connect Identity Provider + + +
    + <.provider_form account={@account} id={@id} form={@form} /> +
    + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex new file mode 100644 index 000000000..7ad08cfa4 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/openid_connect/show.ex @@ -0,0 +1,206 @@ +defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do + use Web, :live_view + import Web.Settings.IdentityProviders.Components + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- + Auth.fetch_provider_by_id(provider_id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) do + {:ok, assign(socket, provider: provider)} + else + _ -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("delete", _params, socket) do + {:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")} + end + + def handle_event("enable", _params, socket) do + attrs = %{disabled_at: nil} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def handle_event("disable", _params, socket) do + attrs = %{disabled_at: DateTime.utc_now()} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/openid_connect//DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" + }> + <%= @provider.name %> + + + <.header> + <:title> + Viewing Identity Provider <%= @provider.name %> + + <:actions> + <.edit_button navigate={ + ~p"/#{@account}/settings/identity_providers/openid_connect/#{@provider.id}/edit" + }> + Edit Identity Provider + + <.button :if={not is_nil(@provider.disabled_at)} phx-click="enable"> + Enable Identity Provider + + <.button + :if={is_nil(@provider.disabled_at)} + phx-click="disable" + data-confirm="Are you sure want to disable this provider?" + > + Disable Identity Provider + + <.button navigate={ + ~p"/#{@provider.account_id}/settings/identity_providers/openid_connect/#{@provider}/redirect" + }> + Reconnect Identity Provider + + + + + <.header> + <:title>Details + + + <.flash_group flash={@flash} /> + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + <%= @provider.name %> +
    + Status + + <.status provider={@provider} /> +
    + Type + + OpenID Connect +
    + Response Type + + <%= @provider.adapter_config["response_type"] %> +
    + Scope + + <%= @provider.adapter_config["scope"] %> +
    + Client ID + + <%= @provider.adapter_config["client_id"] %> +
    + Discovery URL + + + <%= @provider.adapter_config["discovery_document_uri"] %> + <.icon name="hero-arrow-top-right-on-square" class="relative bottom-1 w-3 h-3" /> + +
    + Created + + <.datetime datetime={@provider.inserted_at} /> by <.owner schema={@provider} /> +
    +
    + + <.header> + <:title> + Danger zone + + <:actions> + <.delete_button + data-confirm="Are you sure want to delete this provider along with all related data?" + phx-click="delete" + > + Delete Identity Provider + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex similarity index 57% rename from elixir/apps/web/lib/web/live/settings_live/identity_providers/new/components.ex rename to elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex index c4572519b..9bb010766 100644 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/components.ex @@ -1,4 +1,4 @@ -defmodule Web.SettingsLive.IdentityProviders.New.Components do +defmodule Web.Settings.IdentityProviders.SAML.Components do @moduledoc """ Provides components that can be shared across forms. """ @@ -6,6 +6,7 @@ defmodule Web.SettingsLive.IdentityProviders.New.Components do use Web, :verified_routes import Web.CoreComponents import Web.FormComponents + alias Phoenix.LiveView.JS @doc """ Conditionally renders form fields corresponding to a given provisioning strategy type. @@ -125,4 +126,95 @@ defmodule Web.SettingsLive.IdentityProviders.New.Components do <% end %> """ end + + def provisioning_status(assigns) do + ~H""" + + <.header> + <:title>Provisioning + +
    + + + + + + + + + + + + + + + +
    + Type + + SCIM 2.0 +
    + Endpoint + +
    + + + <%= url(~p"/#{@account}/scim/v2") %> + +
    +
    + Token + +
    + + + + + ••••••••••••••••••••••••••••••••••••••••••••• + + +
    +
    +
    + """ + end + + def toggle_scim_token(js \\ %JS{}) do + js + |> JS.toggle(to: "#visible-token") + |> JS.toggle(to: "#hidden-token") + end end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/saml.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex similarity index 84% rename from elixir/apps/web/lib/web/live/settings_live/identity_providers/new/saml.ex rename to elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex index 5a521f996..71ab59515 100644 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/saml.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/saml.ex @@ -1,6 +1,6 @@ -defmodule Web.SettingsLive.IdentityProviders.New.SAML do +defmodule Web.Settings.IdentityProviders.SAML.New do use Web, :live_view - import Web.SettingsLive.IdentityProviders.New.Components + import Web.Settings.IdentityProviders.SAML.Components # TODO: Use a changeset for this @form_initializer %{ @@ -30,28 +30,26 @@ defmodule Web.SettingsLive.IdentityProviders.New.SAML do {:noreply, push_navigate(socket, - to: ~p"/#{socket.assigns.subject.account}/settings/identity_providers/#{idp.id}" + to: ~p"/#{socket.assigns.subject.account}/settings/identity_providers/saml/#{idp.id}" )} end def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Identity Providers", path: ~p"/#{@subject.account}/settings/identity_providers"}, - %{ - label: "Add Identity Provider", - path: ~p"/#{@subject.account}/settings/identity_providers/new" - }, - %{label: "SAML", path: ~p"/#{@subject.account}/settings/identity_providers/new/saml"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/new"}> + Create Identity Provider + + <.breadcrumb path={~p"/#{@account}/settings/identity_providers/saml/new"}>SAML + + <.header> <:title> Add a new SAML Identity Provider - +
    <.form for={@form} id="saml-form" phx-change="change" phx-submit="submit"> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex new file mode 100644 index 000000000..bf7dcf365 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/saml/show.ex @@ -0,0 +1,163 @@ +defmodule Web.Settings.IdentityProviders.SAML.Show do + use Web, :live_view + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- + Auth.fetch_active_provider_by_id(provider_id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) do + {:ok, assign(socket, provider: provider)} + else + _ -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("delete", _params, socket) do + {:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/saml/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" + }> + <%= @provider.name %> + + + <.header> + <:title> + Viewing Identity Provider <%= @provider.name %> + + <:actions> + <.edit_button navigate={ + ~p"/#{@account}/settings/identity_providers/saml/#{@provider.id}/edit" + }> + Edit Identity Provider + + + + + <.header> + <:title>Details + + + <.flash_group flash={@flash} /> + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + <%= @provider.name %> +
    + Type + + SAML 2.0 +
    + Sign requests + + Yes +
    + Sign metadata + + Yes +
    + Require signed assertions + + Yes +
    + Require signed envelopes + + Yes +
    + Base URL + + Yes +
    + Created + + <.datetime datetime={@provider.inserted_at} /> by <.owner schema={@provider} /> +
    +
    + + <.header> + <:title> + Danger zone + + <:actions> + <.delete_button + data-confirm="Are you sure want to delete this provider along with all related data?" + phx-click="delete" + > + Delete Identity Provider + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex new file mode 100644 index 000000000..23b904014 --- /dev/null +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/system/show.ex @@ -0,0 +1,139 @@ +defmodule Web.Settings.IdentityProviders.System.Show do + use Web, :live_view + import Web.Settings.IdentityProviders.Components + alias Domain.Auth + + def mount(%{"provider_id" => provider_id}, _session, socket) do + with {:ok, provider} <- + Auth.fetch_provider_by_id(provider_id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) do + {:ok, assign(socket, provider: provider)} + else + _ -> raise Web.LiveErrors.NotFoundError + end + end + + def handle_event("delete", _params, socket) do + {:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject) + {:noreply, redirect(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")} + end + + def handle_event("enable", _params, socket) do + attrs = %{disabled_at: nil} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def handle_event("disable", _params, socket) do + attrs = %{disabled_at: DateTime.utc_now()} + {:ok, provider} = Auth.update_provider(socket.assigns.provider, attrs, socket.assigns.subject) + + {:ok, provider} = + Auth.fetch_provider_by_id(provider.id, socket.assigns.subject, + preload: [created_by_identity: [:actor]] + ) + + {:noreply, assign(socket, provider: provider)} + end + + def render(assigns) do + ~H""" + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/settings/identity_providers"}> + Identity Providers Settings + + + <.breadcrumb path={ + ~p"/#{@account}/settings/identity_providers/google_workspace//DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" + }> + <%= @provider.name %> + + + <.header> + <:title> + Viewing Identity Provider <%= @provider.name %> + + <:actions> + <.button :if={not is_nil(@provider.disabled_at)} phx-click="enable"> + Enable Identity Provider + + <.button + :if={is_nil(@provider.disabled_at)} + phx-click="disable" + data-confirm="Are you sure want to disable this provider?" + > + Disable Identity Provider + + + + + <.header> + <:title>Details + + + <.flash_group flash={@flash} /> + +
    + + + + + + + + + + + + + + + + +
    + Name + + <%= @provider.name %> +
    + Status + + <.status provider={@provider} /> +
    + Created + + <.datetime datetime={@provider.inserted_at} /> by <.owner schema={@provider} /> +
    +
    + + <.header> + <:title> + Danger zone + + <:actions> + <.delete_button + data-confirm="Are you sure want to delete this provider along with all related data?" + phx-click="delete" + > + Delete Identity Provider + + + + """ + end +end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/edit.ex b/elixir/apps/web/lib/web/live/settings_live/identity_providers/edit.ex deleted file mode 100644 index 94dbf9239..000000000 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/edit.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule Web.SettingsLive.IdentityProviders.Edit do - use Web, :live_view - - def render(assigns) do - ~H""" - Editing Identity Provider - """ - end -end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/index.ex b/elixir/apps/web/lib/web/live/settings_live/identity_providers/index.ex deleted file mode 100644 index 66857070c..000000000 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/index.ex +++ /dev/null @@ -1,333 +0,0 @@ -defmodule Web.SettingsLive.IdentityProviders.Index do - use Web, :live_view - - def render(assigns) do - ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{ - label: "Identity Providers Settings", - path: ~p"/#{@subject.account}/settings/identity_providers" - } - ]} /> - - <:title> - Identity Providers - - <:actions> - <.add_button navigate={~p"/#{@subject.account}/settings/identity_providers/new"}> - Add Identity Provider - - - -

    - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - href="https://www.firezone.dev/docs/architecture/sso" - target="_blank" - > - Read more about how SSO works in Firezone. - <.icon name="hero-arrow-top-right-on-square" class="-ml-1 mb-3 w-3 h-3" /> - -

    - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - Name - <.link href="#"> - <.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" /> - -
    -
    -
    - Type - <.link href="#"> - <.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" /> - -
    -
    -
    - Status - <.link href="#"> - <.icon name="hero-chevron-up-down-solid" class="w-4 h-4 ml-1" /> - -
    -
    - <.link - navigate={ - ~p"/#{@subject.account}/settings/identity_providers/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - Okta - - - SAML - -
    - - - Synced - <.link - navigate={~p"/#{@subject.account}/users"} - class="text-blue-600 dark:text-blue-500 hover:underline" - > - 17 users - - and - <.link - navigate={~p"/#{@subject.account}/groups"} - class="text-blue-600 dark:text-blue-500 hover:underline" - > - 8 groups - - 47 minutes ago - -
    -
    - - -
    - <.link - navigate={ - ~p"/#{@subject.account}/settings/identity_providers/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - Authentik - - - OIDC - -
    - - - Synced - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/users"} - > - 67 users - - and - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/groups"} - > - 4 groups - - 11 minutes ago - -
    -
    - - -
    - <.link - navigate={ - ~p"/#{@subject.account}/settings/identity_providers/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - class="font-medium text-blue-600 dark:text-blue-500 hover:underline" - > - Google - - - Google Workspace - -
    - - - Synced - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/users"} - > - 221 users - - and - <.link - class="text-blue-600 dark:text-blue-500 hover:underline" - navigate={~p"/#{@subject.account}/groups"} - > - 14 groups - - 57 minutes ago - -
    -
    - - -
    -
    -
    - """ - end -end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new.ex b/elixir/apps/web/lib/web/live/settings_live/identity_providers/new.ex deleted file mode 100644 index d69320db2..000000000 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new.ex +++ /dev/null @@ -1,118 +0,0 @@ -defmodule Web.SettingsLive.IdentityProviders.New do - use Web, :live_view - - def handle_event("submit", %{"next" => next}, socket) do - {:noreply, push_navigate(socket, to: next)} - end - - def mount(_params, _session, socket) do - {:ok, assign(socket, :form, %{})} - end - - def render(assigns) do - ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Identity Providers", path: ~p"/#{@subject.account}/settings/identity_providers"}, - %{ - label: "Add Identity Provider", - path: ~p"/#{@subject.account}/settings/identity_providers/new" - } - ]} /> - - <:title> - Add a new Identity Provider - - -
    -
    -

    Choose type

    - <.form id="identity-provider-type-form" for={@form} phx-submit="submit"> -
    -
    - Identity Provider Type - -
    -
    - - -
    -

    - Authenticate users and synchronize users and groups with preconfigured Google Workspace connector. -

    -
    - -
    -
    - - -

    -
    -

    - Authenticate users with a custom OIDC adapter and synchronize users and groups with just-in-time provisioning. -

    -
    - -
    -
    - - -
    -

    - Authenticate users with a custom SAML 2.0 adapter and synchronize users and groups with SCIM 2.0. -

    -
    -
    -
    -
    - -
    - -
    -
    - """ - end -end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/oidc.ex b/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/oidc.ex deleted file mode 100644 index b89f0768f..000000000 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/new/oidc.ex +++ /dev/null @@ -1,130 +0,0 @@ -defmodule Web.SettingsLive.IdentityProviders.New.OIDC do - use Web, :live_view - import Web.SettingsLive.IdentityProviders.New.Components - - # TODO: Use a changeset for this - @form_initializer %{ - "type" => "oidc", - "scopes" => "openid profile email offline_access", - "provisioning_strategy" => "jit", - "jit_user_filter_type" => "email_allowlist", - "jit_extract_groups" => "false" - } - - def mount(_params, _session, socket) do - {:ok, assign(socket, form: to_form(@form_initializer))} - end - - def handle_event("change", params, socket) do - # TODO: Validations - # changeset = ProvisioningStrategies.changeset(%ProvisioningStrategy{}, params) - - {:noreply, assign(socket, form: to_form(params))} - end - - def handle_event("submit", _params, socket) do - # TODO: Create identity provider - idp = %{id: "DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - - {:noreply, - push_navigate(socket, - to: ~p"/#{socket.assigns.subject.account}/settings/identity_providers/#{idp.id}" - )} - end - - def render(assigns) do - ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Identity Providers", path: ~p"/#{@subject.account}/settings/identity_providers"}, - %{ - label: "Add Identity Provider", - path: ~p"/#{@subject.account}/settings/identity_providers/new" - }, - %{label: "OIDC", path: ~p"/#{@subject.account}/settings/identity_providers/new/oidc"} - ]} /> - - <:title> - Add a new OIDC Identity Provider - - -
    -
    - <.form for={@form} id="oidc-form" phx-change="change" phx-submit="submit"> -

    OIDC configuration

    -
    -
    - <.input - label="Name" - autocomplete="off" - field={@form[:name]} - class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 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="Name this identity provider" - required - /> -

    - A friendly name for this identity provider. This will be displayed to end-users. -

    -
    - -
    - <.input - label="Scopes" - autocomplete="off" - field={@form[:scopes]} - class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 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="OIDC scopes to request" - required - /> -

    - A space-delimited list of scopes to request from your identity provider. In most cases you shouldn't need to change this. -

    -
    - -
    - <.input - label="Client ID" - autocomplete="off" - field={@form[:client_id]} - class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 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" - required - /> -
    -
    - <.input - label="Client secret" - autocomplete="off" - field={@form[:client_secret]} - class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 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" - required - /> -
    -
    - <.input - label="Discovery URI" - autocomplete="off" - field={@form[:discovery_uri]} - class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 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" - required - /> -
    -
    - - <.provisioning_strategy_form form={@form} /> - -
    - -
    - -
    -
    - """ - end -end diff --git a/elixir/apps/web/lib/web/live/settings_live/identity_providers/show.ex b/elixir/apps/web/lib/web/live/settings_live/identity_providers/show.ex deleted file mode 100644 index b3d0baf7b..000000000 --- a/elixir/apps/web/lib/web/live/settings_live/identity_providers/show.ex +++ /dev/null @@ -1,237 +0,0 @@ -defmodule Web.SettingsLive.IdentityProviders.Show do - use Web, :live_view - alias Phoenix.LiveView.JS - - def toggle_scim_token(js \\ %JS{}) do - js - |> JS.toggle(to: "#visible-token") - |> JS.toggle(to: "#hidden-token") - end - - def render(assigns) do - assigns = - assign(assigns, identity_provider: %{scim_token: "DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}) - - ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Identity Providers", path: ~p"/#{@subject.account}/settings/identity_providers"}, - %{ - label: "Okta", - path: - ~p"/#{@subject.account}/settings/identity_providers/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - ]} /> - - <:title> - Viewing Identity Provider Okta - - <:actions> - <.edit_button navigate={ - ~p"/#{@subject.account}/settings/identity_providers/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - }> - Edit Identity Provider - - - - - <.section_header> - <:title>Details - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - Name - - Okta -
    - Type - - SAML 2.0 -
    - Sign requests - - Yes -
    - Sign metadata - - Yes -
    - Require signed assertions - - Yes -
    - Require signed envelopes - - Yes -
    - Base URL - - Yes -
    - Created - - 4/15/22 12:32 PM by - <.link - class="text-blue-600 hover:underline" - navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"} - > - Andrew Dryga - -
    -
    - - <.section_header> - <:title>Provisioning - -
    - - - - - - - - - - - - - - - -
    - Type - - SCIM 2.0 -
    - Endpoint - -
    - - - <%= url(~p"/#{@subject.account}/scim/v2") %> - -
    -
    - Token - -
    - - - - - ••••••••••••••••••••••••••••••••••••••••••••• - - -
    -
    -
    - - <.section_header> - <:title> - Danger zone - - <:actions> - <.delete_button> - Delete Identity Provider - - - - """ - end -end diff --git a/elixir/apps/web/lib/web/live/users_live/edit.ex b/elixir/apps/web/lib/web/live/users/edit.ex similarity index 90% rename from elixir/apps/web/lib/web/live/users_live/edit.ex rename to elixir/apps/web/lib/web/live/users/edit.ex index ec4eaa785..3d455aeaa 100644 --- a/elixir/apps/web/lib/web/live/users_live/edit.ex +++ b/elixir/apps/web/lib/web/live/users/edit.ex @@ -1,27 +1,22 @@ -defmodule Web.UsersLive.Edit do +defmodule Web.Users.Edit do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Users", path: ~p"/#{@subject.account}/users"}, - %{ - label: "Bou Kheir, Jamil", - path: ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - }, - %{ - label: "Edit", - path: ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/actors"}>Users + <.breadcrumb path={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Jamil Bou Kheir + + <.breadcrumb path={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> + Edit + + + <.header> <:title> Editing user Bou Kheir, Jamil - +
    diff --git a/elixir/apps/web/lib/web/live/users_live/index.ex b/elixir/apps/web/lib/web/live/users/index.ex similarity index 90% rename from elixir/apps/web/lib/web/live/users_live/index.ex rename to elixir/apps/web/lib/web/live/users/index.ex index 667eb0ca8..f239b56f2 100644 --- a/elixir/apps/web/lib/web/live/users_live/index.ex +++ b/elixir/apps/web/lib/web/live/users/index.ex @@ -1,24 +1,21 @@ -defmodule Web.UsersLive.Index do +defmodule Web.Users.Index do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Users", path: ~p"/#{@subject.account}/users"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/actors"}>Users + + <.header> <:title> All users <:actions> - <.add_button navigate={~p"/#{@subject.account}/users/new"}> + <.add_button navigate={~p"/#{@account}/actors/new"}> Add a new user - +
    @@ -88,7 +85,7 @@ defmodule Web.UsersLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.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" > Bou Kheir, Jamil @@ -127,9 +124,7 @@ defmodule Web.UsersLive.Index do
  • <.link - navigate={ - ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Edit @@ -153,7 +148,7 @@ defmodule Web.UsersLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.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" > Dryga, Andrew @@ -192,9 +187,7 @@ defmodule Web.UsersLive.Index do
  • <.link - navigate={ - ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Edit @@ -218,7 +211,7 @@ defmodule Web.UsersLive.Index do class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white" > <.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 @@ -257,9 +250,7 @@ defmodule Web.UsersLive.Index do
  • <.link - navigate={ - ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - } + navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"} class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" > Edit @@ -280,7 +271,7 @@ defmodule Web.UsersLive.Index do
  • - <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/users"} /> + <.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/actors"} />
    """ end diff --git a/elixir/apps/web/lib/web/live/users_live/new.ex b/elixir/apps/web/lib/web/live/users/new.ex similarity index 93% rename from elixir/apps/web/lib/web/live/users_live/new.ex rename to elixir/apps/web/lib/web/live/users/new.ex index a4e23442b..875540fea 100644 --- a/elixir/apps/web/lib/web/live/users_live/new.ex +++ b/elixir/apps/web/lib/web/live/users/new.ex @@ -1,20 +1,17 @@ -defmodule Web.UsersLive.New do +defmodule Web.Users.New do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Users", path: ~p"/#{@subject.account}/users"}, - %{label: "Add user", path: ~p"/#{@subject.account}/users/new"} - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/actors"}>Users + <.breadcrumb path={~p"/#{@account}/actors/new"}>Add User + + <.header> <:title> Add a new user - +
    diff --git a/elixir/apps/web/lib/web/live/users_live/show.ex b/elixir/apps/web/lib/web/live/users/show.ex similarity index 83% rename from elixir/apps/web/lib/web/live/users_live/show.ex rename to elixir/apps/web/lib/web/live/users/show.ex index d875ff655..91f49adc9 100644 --- a/elixir/apps/web/lib/web/live/users_live/show.ex +++ b/elixir/apps/web/lib/web/live/users/show.ex @@ -1,30 +1,24 @@ -defmodule Web.UsersLive.Show do +defmodule Web.Users.Show do use Web, :live_view def render(assigns) do ~H""" - <.section_header> - <:breadcrumbs> - <.breadcrumbs entries={[ - %{label: "Home", path: ~p"/#{@subject.account}/dashboard"}, - %{label: "Users", path: ~p"/#{@subject.account}/users"}, - %{ - label: "Bou Kheir, Jamil", - path: ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89" - } - ]} /> - + <.breadcrumbs home_path={~p"/#{@account}/dashboard"}> + <.breadcrumb path={~p"/#{@account}/actors"}>Users + <.breadcrumb path={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}> + Jamil Bou Kheir + + + <.header> <:title> Viewing User Bou Kheir, Jamil <:actions> - <.edit_button navigate={ - ~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit" - }> + <.edit_button navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89/edit"}> Edit user - +
    @@ -95,7 +89,7 @@ defmodule Web.UsersLive.Show do
    <.link - navigate={~p"/#{@subject.account}/groups/55DDA8CB-69A7-48FC-9048-639021C205A2"} + navigate={~p"/#{@account}/groups/55DDA8CB-69A7-48FC-9048-639021C205A2"} class="text-blue-600 hover:underline" > Engineering @@ -117,7 +111,7 @@ defmodule Web.UsersLive.Show do
    - <.section_header> + <.header> <:title> Danger zone @@ -126,7 +120,7 @@ defmodule Web.UsersLive.Show do Delete user - + """ end end diff --git a/elixir/apps/web/lib/web/live_errors.ex b/elixir/apps/web/lib/web/live_errors.ex new file mode 100644 index 000000000..501d82b67 --- /dev/null +++ b/elixir/apps/web/lib/web/live_errors.ex @@ -0,0 +1,10 @@ +defmodule Web.LiveErrors do + defmodule NotFoundError do + defexception message: "Not Found" + + defimpl Plug.Exception do + def status(_exception), do: 404 + def actions(_exception), do: [] + end + end +end diff --git a/elixir/apps/web/lib/web/protocols.ex b/elixir/apps/web/lib/web/protocols.ex new file mode 100644 index 000000000..a0a053300 --- /dev/null +++ b/elixir/apps/web/lib/web/protocols.ex @@ -0,0 +1,7 @@ +defimpl Phoenix.HTML.Safe, for: Postgrex.INET do + def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet) +end + +defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do + def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port) +end diff --git a/elixir/apps/web/lib/web/router.ex b/elixir/apps/web/lib/web/router.ex index d8a0e5075..45430f6e1 100644 --- a/elixir/apps/web/lib/web/router.ex +++ b/elixir/apps/web/lib/web/router.ex @@ -54,11 +54,11 @@ defmodule Web.Router do Web.Sandbox, {Web.Auth, :redirect_if_user_is_authenticated} ] do - live "/", Auth.ProvidersLive, :new + live "/", Auth.SignIn # Adapter-specific routes ## Email - live "/providers/email/:provider_id", Auth.EmailLive, :confirm + live "/providers/email/:provider_id", Auth.Email end scope "/providers/:provider_id" do @@ -86,11 +86,6 @@ defmodule Web.Router do pipe_through [:browser] get "/sign_out", AuthController, :sign_out - - live_session :landing, - on_mount: [Web.Sandbox] do - live "/", LandingLive - end end scope "/:account_id", Web do @@ -100,55 +95,106 @@ defmodule Web.Router do on_mount: [ Web.Sandbox, {Web.Auth, :ensure_authenticated}, - {Web.Auth, :ensure_account_admin_user_actor} + {Web.Auth, :ensure_account_admin_user_actor}, + {Web.Auth, :mount_account} ] do - live "/dashboard", DashboardLive + live "/dashboard", Dashboard - # Users - live "/users", UsersLive.Index - live "/users/new", UsersLive.New - live "/users/:id/edit", UsersLive.Edit - live "/users/:id", UsersLive.Show + scope "/actors", Users do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end - # Groups - live "/groups", GroupsLive.Index - live "/groups/new", GroupsLive.New - live "/groups/:id/edit", GroupsLive.Edit - live "/groups/:id", GroupsLive.Show + scope "/groups", Groups do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end - # Devices - live "/devices", DevicesLive.Index - live "/devices/:id", DevicesLive.Show + scope "/devices", Devices do + live "/", Index + live "/:id", Show + end - # Gateways - live "/gateways", GatewaysLive.Index - live "/gateways/new", GatewaysLive.New - live "/gateways/:id/edit", GatewaysLive.Edit - live "/gateways/:id", GatewaysLive.Show + scope "/gateways", Gateways do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end - # Resources - live "/resources", ResourcesLive.Index - live "/resources/new", ResourcesLive.New - live "/resources/:id/edit", ResourcesLive.Edit - live "/resources/:id", ResourcesLive.Show + scope "/resources", Resources do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end - # Policies - live "/policies", PoliciesLive.Index - live "/policies/new", PoliciesLive.New - live "/policies/:id/edit", PoliciesLive.Edit - live "/policies/:id", PoliciesLive.Show + scope "/policies", Policies do + live "/", Index + live "/new", New + live "/:id/edit", Edit + live "/:id", Show + end - # Settings - live "/settings/account", SettingsLive.Account - live "/settings/identity_providers", SettingsLive.IdentityProviders.Index - live "/settings/identity_providers/new", SettingsLive.IdentityProviders.New - live "/settings/identity_providers/new/oidc", SettingsLive.IdentityProviders.New.OIDC - live "/settings/identity_providers/new/saml", SettingsLive.IdentityProviders.New.SAML - live "/settings/identity_providers/:id", SettingsLive.IdentityProviders.Show - live "/settings/identity_providers/:id/edit", SettingsLive.IdentityProviders.Edit - live "/settings/dns", SettingsLive.Dns - live "/settings/api_tokens", SettingsLive.ApiTokens.Index - live "/settings/api_tokens/new", SettingsLive.ApiTokens.New + scope "/settings", Settings do + live "/account", Account + + scope "/identity_providers", IdentityProviders do + live "/", Index + live "/new", New + + scope "/saml", SAML do + live "/new", New + live "/:provider_id", Show + live "/:provider_id/edit", Edit + end + + scope "/openid_connect", OpenIDConnect do + live "/new", New + live "/:provider_id", Show + live "/:provider_id/edit", Edit + + # OpenID Connection + get "/:provider_id/redirect", Connect, :redirect_to_idp + get "/:provider_id/handle_callback", Connect, :handle_idp_callback + end + + scope "/google_workspace", GoogleWorkspace do + live "/new", New + live "/:provider_id", Show + live "/:provider_id/edit", Edit + + # OpenID Connection + get "/:provider_id/redirect", Connect, :redirect_to_idp + get "/:provider_id/handle_callback", Connect, :handle_idp_callback + end + + scope "/system", System do + live "/:provider_id", Show + end + end + + live "/dns", DNS + + scope "/api_tokens", APITokens do + live "/", Index + live "/new", New + end + end + end + end + + scope "/", Web do + pipe_through [:browser] + + live_session :landing, + on_mount: [Web.Sandbox] do + live "/:account_id/", Landing + live "/", Landing end end end diff --git a/elixir/apps/web/mix.exs b/elixir/apps/web/mix.exs index a01316ef8..1649ac096 100644 --- a/elixir/apps/web/mix.exs +++ b/elixir/apps/web/mix.exs @@ -50,6 +50,10 @@ defmodule Web.MixProject do {:gettext, "~> 0.20"}, {:remote_ip, "~> 1.0"}, + # CLDR + {:ex_cldr_dates_times, "~> 2.13"}, + {:ex_cldr_numbers, "~> 2.31"}, + # Asset pipeline deps {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, diff --git a/elixir/apps/web/test/support/conn_case.ex b/elixir/apps/web/test/support/conn_case.ex index a07bfbb6f..cc1769ffb 100644 --- a/elixir/apps/web/test/support/conn_case.ex +++ b/elixir/apps/web/test/support/conn_case.ex @@ -1,6 +1,7 @@ defmodule Web.ConnCase do use ExUnit.CaseTemplate use Domain.CaseTemplate + import Phoenix.LiveViewTest using do quote do @@ -42,20 +43,70 @@ defmodule Web.ConnCase do subject = Domain.Auth.build_subject(identity, expires_in, user_agent, conn.remote_ip) conn - |> Web.Auth.renew_session() |> Web.Auth.put_subject_in_session(subject) + |> Plug.Conn.assign(:subject, subject) end - # @doc """ - # Logs the given `user` into the `conn`. + ### Helpers to test LiveView forms - # It returns an updated `conn`. - # """ - # def log_in_user(conn, user) do - # token = Domain.Accounts.generate_user_session_token(user) + def find_inputs(html, selector) do + html + |> Floki.find("#{selector} input") + |> Enum.flat_map(&Floki.attribute(&1, "name")) + |> Enum.sort() + end - # conn - # |> Phoenix.ConnTest.init_test_session(%{}) - # |> Plug.Conn.put_session(:user_token, token) - # end + def find_inputs(%Phoenix.LiveViewTest.Element{} = form_element) do + form_element |> render() |> find_inputs(form_element.selector) + end + + def form_validation_errors(html_or_form_element) do + html_or_form_element + |> ensure_rendered() + |> Floki.find("[data-validation-error-for]") + |> Enum.map(fn html_element -> + [field] = Floki.attribute(html_element, "data-validation-error-for") + message = element_to_text(html_element) + {field, message} + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + end + + defp ensure_rendered(%Phoenix.LiveViewTest.Element{} = form_element), do: render(form_element) + defp ensure_rendered(form_html), do: form_html + + @doc """ + Renders a change and allows to run assertions on it, resetting the form data afterwards. + """ + def validate_change(form_element, attrs, callback) do + form_html = render_change(form_element, attrs) + callback.(form_element, form_html) + render_change(form_element, form_element.form_data) + form_element + end + + ### Helpers to test LiveView tables + + def table_row_as_text_columns(row_html) do + row_html + |> Floki.find("td") + |> elements_to_text() + end + + def table_to_text(table_html) do + table_html + |> Floki.find("tr") + |> Enum.map(&table_row_as_text_columns/1) + end + + defp elements_to_text(elements) do + Enum.map(elements, &element_to_text/1) + end + + defp element_to_text(element) do + element + |> Floki.text() + |> String.replace(~r|[\n\s ]+|, " ") + |> String.trim() + end end diff --git a/elixir/apps/web/test/web/auth_test.exs b/elixir/apps/web/test/web/auth_test.exs index dcd959afe..e56208ee6 100644 --- a/elixir/apps/web/test/web/auth_test.exs +++ b/elixir/apps/web/test/web/auth_test.exs @@ -30,10 +30,6 @@ defmodule Web.AuthTest do test "redirects to dashboard after sign in as account admin", %{admin_subject: subject} do assert signed_in_path(subject) == ~p"/#{subject.account}/dashboard" end - - test "redirects to account landing after sign in as account user", %{user_subject: subject} do - assert signed_in_path(subject) == ~p"/#{subject.account}" - end end describe "put_subject_in_session/2" do @@ -153,7 +149,7 @@ defmodule Web.AuthTest do end describe "redirect_if_user_is_authenticated/2" do - test "redirects if user is authenticated", %{conn: conn, user_subject: subject} do + test "redirects if user is authenticated", %{conn: conn, admin_subject: subject} do conn = conn |> assign(:subject, subject) diff --git a/elixir/apps/web/test/web/controllers/auth_controller_test.exs b/elixir/apps/web/test/web/controllers/auth_controller_test.exs index 5dcab09fe..f0ecf99b0 100644 --- a/elixir/apps/web/test/web/controllers/auth_controller_test.exs +++ b/elixir/apps/web/test/web/controllers/auth_controller_test.exs @@ -1,6 +1,6 @@ defmodule Web.AuthControllerTest do use Web.ConnCase, async: true - alias Domain.{AccountsFixtures, AuthFixtures} + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} setup do Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) @@ -103,8 +103,16 @@ defmodule Web.AuthControllerTest do provider = AuthFixtures.create_userpass_provider(account: account) password = "Firezone1234" + actor = + ActorsFixtures.create_actor( + type: :account_admin_user, + account: account, + provider: provider + ) + identity = AuthFixtures.create_identity( + actor: actor, account: account, provider: provider, provider_virtual_state: %{"password" => password, "password_confirmation" => password} @@ -123,7 +131,9 @@ defmodule Web.AuthControllerTest do } ) + assert conn.assigns.flash == %{} assert redirected_to(conn) == "/foo/bar" + assert is_nil(get_session(conn, :user_return_to)) end test "redirects to the dashboard when credentials are valid and return path is empty", %{ @@ -161,10 +171,18 @@ defmodule Web.AuthControllerTest do provider = AuthFixtures.create_userpass_provider(account: account) password = "Firezone1234" + actor = + ActorsFixtures.create_actor( + type: :account_admin_user, + account: account, + provider: provider + ) + identity = AuthFixtures.create_identity( account: account, provider: provider, + actor: actor, provider_virtual_state: %{"password" => password, "password_confirmation" => password} ) @@ -308,7 +326,15 @@ defmodule Web.AuthControllerTest do test "redirects to the return to path when credentials are valid", %{conn: conn} do account = AccountsFixtures.create_account() provider = AuthFixtures.create_email_provider(account: account) - identity = AuthFixtures.create_identity(account: account, provider: provider) + + actor = + ActorsFixtures.create_actor( + type: :account_admin_user, + account: account, + provider: provider + ) + + identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor) conn = conn @@ -321,7 +347,9 @@ defmodule Web.AuthControllerTest do } ) + assert conn.assigns.flash == %{} assert redirected_to(conn) == "/foo/bar" + assert is_nil(get_session(conn, :user_return_to)) end test "redirects to the dashboard when credentials are valid and return path is empty", %{ @@ -349,7 +377,15 @@ defmodule Web.AuthControllerTest do test "renews the session when credentials are valid", %{conn: conn} do account = AccountsFixtures.create_account() provider = AuthFixtures.create_email_provider(account: account) - identity = AuthFixtures.create_identity(account: account, provider: provider) + + actor = + ActorsFixtures.create_actor( + type: :account_admin_user, + account: account, + provider: provider + ) + + identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor) conn = conn diff --git a/elixir/apps/web/test/web/live/auth_live/email_live_test.exs b/elixir/apps/web/test/web/live/auth/email_test.exs similarity index 94% rename from elixir/apps/web/test/web/live/auth_live/email_live_test.exs rename to elixir/apps/web/test/web/live/auth/email_test.exs index b579ad00d..c28884f2b 100644 --- a/elixir/apps/web/test/web/live/auth_live/email_live_test.exs +++ b/elixir/apps/web/test/web/live/auth/email_test.exs @@ -1,4 +1,4 @@ -defmodule Web.Auth.EmailLiveTest do +defmodule Web.Auth.EmailTest do use Web.ConnCase, async: true alias Domain.{AccountsFixtures, AuthFixtures} diff --git a/elixir/apps/web/test/web/live/auth_live/providers_live_test.exs b/elixir/apps/web/test/web/live/auth/sign_in_test.exs similarity index 97% rename from elixir/apps/web/test/web/live/auth_live/providers_live_test.exs rename to elixir/apps/web/test/web/live/auth/sign_in_test.exs index 8e4195afc..228f48401 100644 --- a/elixir/apps/web/test/web/live/auth_live/providers_live_test.exs +++ b/elixir/apps/web/test/web/live/auth/sign_in_test.exs @@ -1,4 +1,4 @@ -defmodule Web.Auth.ProvidersLiveTest do +defmodule Web.Auth.SignInTest do use Web.ConnCase, async: true alias Domain.{AccountsFixtures, AuthFixtures} diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs new file mode 100644 index 000000000..461a1ff89 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs @@ -0,0 +1,221 @@ +defmodule Web.Auth.Settings.IdentityProviders.GoogleWorkspace.Connect do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + describe "redirect_to_idp/2" do + setup do + account = AccountsFixtures.create_account() + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + subject = AuthFixtures.create_subject(identity) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity, + subject: subject + } + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get( + ~p"/#{account_id}/settings/identity_providers/google_workspace/#{provider_id}/redirect" + ) + + assert redirected_to(conn) == "/#{account_id}/sign_in" + assert flash(conn, :error) == "You must log in to access this page." + end + + test "redirects with an error when provider does not exist", %{identity: identity, conn: conn} do + account = AccountsFixtures.create_account() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider_id}/redirect" + ) + + assert redirected_to(conn) == "/#{account.id}/settings/identity_providers" + assert flash(conn, :error) == "Provider does not exist." + end + + test "redirects to IdP when provider exists", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider.id}/redirect", + %{} + ) + + assert to = redirected_to(conn) + uri = URI.parse(to) + assert uri.host == "localhost" + assert uri.path == "/authorize" + + callback_url = + url( + ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider.id}/handle_callback" + ) + + {state, verifier} = conn.cookies["fz_auth_state_#{provider.id}"] |> :erlang.binary_to_term() + code_challenge = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_challenge(verifier) + + assert URI.decode_query(uri.query) == %{ + "access_type" => "offline", + "client_id" => provider.adapter_config["client_id"], + "code_challenge" => code_challenge, + "code_challenge_method" => "S256", + "redirect_uri" => callback_url, + "response_type" => "code", + "scope" => "openid email profile", + "state" => state + } + end + end + + describe "handle_idp_callback/2" do + setup do + account = AccountsFixtures.create_account() + %{account: account} + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback", %{ + "state" => "foo", + "code" => "bar" + }) + + assert redirected_to(conn) == "/#{account_id}/sign_in" + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects with an error when state cookie does not exist", %{ + account: account, + conn: conn + } do + {provider, _bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/handle_callback", + %{ + "state" => "XOXOX", + "code" => "bar" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}" + + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects to the dashboard when credentials are valid and return path is empty", %{ + account: account, + conn: conn + } do + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/redirect", + %{} + ) + + {token, _claims} = AuthFixtures.generate_openid_connect_token(provider, identity) + AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token}) + AuthFixtures.expect_userinfo(bypass) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + {state, _verifier} = redirected_conn.cookies[cookie_key] |> :erlang.binary_to_term([:safe]) + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}" + + assert %{ + "live_socket_id" => "actors_sessions:" <> socket_id, + "preferred_locale" => "en_US", + "session_token" => session_token + } = conn.private.plug_session + + assert socket_id == identity.actor_id + assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert subject.identity.id == identity.id + assert subject.identity.last_seen_user_agent == "testing" + assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} + assert subject.identity.last_seen_at + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + + assert %{ + "access_token" => _, + "claims" => %{}, + "expires_at" => _, + "refresh_token" => _, + "userinfo" => %{} + } = provider.adapter_state + + assert is_nil(provider.disabled_at) + end + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs new file mode 100644 index 000000000..aef57da78 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/edit_test.exs @@ -0,0 +1,157 @@ +defmodule Web.Auth.Settings.IdentityProviders.GoogleWorkspace.EditTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + assert live( + conn, + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/edit" + ) == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/edit") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :google_workspace, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/edit") + + form = + lv + |> form("form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + result = render_submit(form) + assert provider = Domain.Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert result == + {:error, + {:redirect, + %{ + to: + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/redirect" + }}} + + assert provider.name == provider_attrs.name + assert provider.adapter == :google_workspace + + assert provider.adapter_config["client_id"] == adapter_config_attrs["client_id"] + assert provider.adapter_config["client_secret"] == adapter_config_attrs["client_secret"] + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :google_workspace, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/edit") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + validate_change(form, %{provider: %{name: String.duplicate("a", 256)}}, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs new file mode 100644 index 000000000..2af138760 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/new_test.exs @@ -0,0 +1,145 @@ +defmodule Web.Auth.Settings.IdentityProviders.GoogleWorkspace.NewTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + + %{ + account: account, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/google_workspace/new") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/new") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :google_workspace, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + result = render_submit(form) + assert provider = Domain.Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert result == + {:error, + {:redirect, + %{ + to: + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/redirect" + }}} + + assert provider.name == provider_attrs.name + assert provider.adapter == :google_workspace + + assert provider.adapter_config["client_id"] == + provider_attrs.adapter_config["client_id"] + + assert provider.adapter_config["client_secret"] == + provider_attrs.adapter_config["client_secret"] + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + + adapter_config_attrs = + Map.drop(adapter_config_attrs, [ + "response_type", + "discovery_document_uri", + "scope" + ]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :google_workspace, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + validate_change(form, %{provider: %{name: String.duplicate("a", 256)}}, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs new file mode 100644 index 000000000..00ae5aa44 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs @@ -0,0 +1,107 @@ +defmodule Web.Auth.Settings.IdentityProviders.GoogleWorkspace.ShowTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + bypass: bypass + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider details", %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + conn: conn + } do + inserted_at = Cldr.DateTime.to_string!(provider.inserted_at, Web.CLDR, format: :short) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}") + + assert has_element?(lv, "a", "Edit Identity Provider") + assert has_element?(lv, "button", "Reconnect Identity Provider") + + active = + lv + |> element("table") + |> render() + + assert table_to_text(active) == [ + [provider.name], + ["Active"], + [provider.adapter_config["client_id"]], + ["#{inserted_at} by System"] + ] + + disabled = + lv + |> element("button", "Disable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(disabled) == [ + [provider.name], + ["Disabled"], + [provider.adapter_config["client_id"]], + ["#{inserted_at} by System"] + ] + + provider + |> Ecto.Changeset.change( + created_by: :identity, + created_by_identity_id: identity.id + ) + |> Repo.update!() + + enabled = + lv + |> element("button", "Enable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(enabled) == [ + [provider.name], + ["Active"], + [provider.adapter_config["client_id"]], + ["#{inserted_at} by #{actor.name}"] + ] + + assert lv + |> element("button", "Delete Identity Provider") + |> render_click() == + {:error, {:redirect, %{to: ~p"/#{account}/settings/identity_providers"}}} + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs new file mode 100644 index 000000000..ef0c51da2 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/index_test.exs @@ -0,0 +1,207 @@ +defmodule Web.Auth.Settings.IdentityProviders.IndexTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + subject = AuthFixtures.create_subject(identity) + + %{ + account: account, + actor: actor, + openid_connect_provider: provider, + bypass: bypass, + identity: identity, + subject: subject + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + assert live(conn, ~p"/#{account}/settings/identity_providers") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders table with all providers", %{ + account: account, + openid_connect_provider: openid_connect_provider, + identity: identity, + subject: subject, + conn: conn + } do + email_provider = AuthFixtures.create_email_provider(account: account) + {:ok, _email_provider} = Domain.Auth.disable_provider(email_provider, subject) + userpass_provider = AuthFixtures.create_userpass_provider(account: account) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers") + + rows = lv |> element("tbody#providers") |> render() |> Floki.find("tr") + rows_as_text = Enum.map(rows, &table_row_as_text_columns/1) + + assert length(rows_as_text) == 4 + + assert [ + openid_connect_provider.name, + "OpenID Connect", + "Active", + "Created 1 identity and 0 groups" + ] in rows_as_text + + assert [ + email_provider.name, + "Magic Link", + "Disabled", + "Created 0 identities and 0 groups" + ] in rows_as_text + + assert [ + userpass_provider.name, + "Username & Password", + "Active", + "Created 0 identities and 0 groups" + ] in rows_as_text + end + + test "renders google_workspace provider", %{ + account: account, + identity: identity, + conn: conn + } do + {provider, _bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_google_workspace_provider(account: account) + + conn = authorize_conn(conn, identity) + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "Google Workspace", + "Active", + "Never synced" + ] + + AuthFixtures.create_identity(account: account, provider: provider) + AuthFixtures.create_identity(account: account, provider: provider) + ActorsFixtures.create_group(account: account, provider: provider) + one_hour_ago = DateTime.utc_now() |> DateTime.add(-1, :hour) + provider |> Ecto.Changeset.change(last_synced_at: one_hour_ago) |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "Google Workspace", + "Active", + "Synced 2 identities and 1 group 1 hour ago" + ] + + provider + |> Ecto.Changeset.change( + disabled_at: DateTime.utc_now(), + adapter_state: %{status: :pending_access_token} + ) + |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "Google Workspace", + "Pending access token, reconnect identity provider", + "Synced 2 identities and 1 group 1 hour ago" + ] + end + + test "shows provisioning status for openid_connect provider", %{ + account: account, + openid_connect_provider: provider, + identity: identity, + conn: conn + } do + conn = authorize_conn(conn, identity) + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "OpenID Connect", + "Active", + "Created 1 identity and 0 groups" + ] + + AuthFixtures.create_identity(account: account, provider: provider) + AuthFixtures.create_identity(account: account, provider: provider) + ActorsFixtures.create_group(account: account, provider: provider) + provider |> Ecto.Changeset.change(last_synced_at: DateTime.utc_now()) |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "OpenID Connect", + "Active", + "Created 3 identities and 1 group" + ] + end + + test "shows provisioning status for other providers", %{ + account: account, + identity: identity, + conn: conn + } do + provider = AuthFixtures.create_token_provider(account: account) + + conn = authorize_conn(conn, identity) + + {:ok, lv, _html} = live(conn, ~p"/#{account}/settings/identity_providers") + element = element(lv, "#providers-#{provider.id}") + assert has_element?(element) + + row = render(element) + + assert table_row_as_text_columns(row) == [ + provider.name, + "API Access Token", + "Active", + "Created 0 identities and 0 groups" + ] + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs new file mode 100644 index 000000000..8652bf42f --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/new_test.exs @@ -0,0 +1,52 @@ +defmodule Web.Auth.Settings.IdentityProviders.NewTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + %{ + account: account, + actor: actor, + openid_connect_provider: provider, + bypass: bypass, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{account: account, conn: conn} do + assert live(conn, ~p"/#{account}/settings/identity_providers/new") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders available options", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/new") + + assert has_element?(lv, "#idp-option-google_workspace") + assert html =~ "Google Workspace" + + assert has_element?(lv, "#idp-option-openid_connect") + assert html =~ "OpenID Connect" + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs new file mode 100644 index 000000000..7c9f25042 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/connect_test.exs @@ -0,0 +1,213 @@ +defmodule Web.Auth.Settings.IdentityProviders.OpenIDConnect.Connect do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + describe "redirect_to_idp/2" do + setup do + account = AccountsFixtures.create_account() + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + subject = AuthFixtures.create_subject(identity) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity, + subject: subject + } + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get( + ~p"/#{account_id}/settings/identity_providers/openid_connect/#{provider_id}/redirect" + ) + + assert redirected_to(conn) == "/#{account_id}/sign_in" + assert flash(conn, :error) == "You must log in to access this page." + end + + test "redirects with an error when provider does not exist", %{identity: identity, conn: conn} do + account = AccountsFixtures.create_account() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider_id}/redirect" + ) + + assert redirected_to(conn) == "/#{account.id}/settings/identity_providers" + assert flash(conn, :error) == "Provider does not exist." + end + + test "redirects to IdP when provider exists", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider.id}/redirect", + %{} + ) + + assert to = redirected_to(conn) + uri = URI.parse(to) + assert uri.host == "localhost" + assert uri.path == "/authorize" + + callback_url = + url( + ~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider.id}/handle_callback" + ) + + {state, verifier} = conn.cookies["fz_auth_state_#{provider.id}"] |> :erlang.binary_to_term() + code_challenge = Domain.Auth.Adapters.OpenIDConnect.PKCE.code_challenge(verifier) + + assert URI.decode_query(uri.query) == %{ + "access_type" => "offline", + "client_id" => provider.adapter_config["client_id"], + "code_challenge" => code_challenge, + "code_challenge_method" => "S256", + "redirect_uri" => callback_url, + "response_type" => "code", + "scope" => "openid email profile", + "state" => state + } + end + end + + describe "handle_idp_callback/2" do + setup do + account = AccountsFixtures.create_account() + %{account: account} + end + + test "redirects to login page when user is not signed in", %{conn: conn} do + account_id = Ecto.UUID.generate() + provider_id = Ecto.UUID.generate() + + conn = + conn + |> get(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback", %{ + "state" => "foo", + "code" => "bar" + }) + + assert redirected_to(conn) == "/#{account_id}/sign_in" + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects with an error when state cookie does not exist", %{ + account: account, + conn: conn + } do + {provider, _bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/handle_callback", + %{ + "state" => "XOXOX", + "code" => "bar" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}" + + assert flash(conn, :error) == "Your session has expired, please try again." + end + + test "redirects to the dashboard when credentials are valid and return path is empty", %{ + account: account, + conn: conn + } do + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/redirect", + %{} + ) + + {token, _claims} = AuthFixtures.generate_openid_connect_token(provider, identity) + AuthFixtures.expect_refresh_token(bypass, %{"id_token" => token}) + AuthFixtures.expect_userinfo(bypass) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + {state, _verifier} = redirected_conn.cookies[cookie_key] |> :erlang.binary_to_term([:safe]) + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/openid_connect/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}" + + assert %{ + "live_socket_id" => "actors_sessions:" <> socket_id, + "preferred_locale" => "en_US", + "session_token" => session_token + } = conn.private.plug_session + + assert socket_id == identity.actor_id + assert {:ok, subject} = Domain.Auth.sign_in(session_token, "testing", conn.remote_ip) + assert subject.identity.id == identity.id + assert subject.identity.last_seen_user_agent == "testing" + assert subject.identity.last_seen_remote_ip.address == {127, 0, 0, 1} + assert subject.identity.last_seen_at + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + assert provider.adapter_state == %{"status" => "connected"} + assert is_nil(provider.disabled_at) + end + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/edit_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/edit_test.exs new file mode 100644 index 000000000..7ab87fb98 --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/edit_test.exs @@ -0,0 +1,150 @@ +defmodule Web.Auth.Settings.IdentityProviders.OpenIDConnect.EditTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + + %{ + bypass: bypass, + account: account, + provider: provider, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/edit") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/edit") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][discovery_document_uri]", + "provider[adapter_config][response_type]", + "provider[adapter_config][scope]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + adapter_config_attrs = Map.drop(adapter_config_attrs, ["response_type"]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :openid_connect, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/edit") + + form = + lv + |> form("form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + result = render_submit(form) + assert provider = Domain.Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert result == + {:error, + {:redirect, + %{ + to: + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/redirect" + }}} + + assert provider.name == provider_attrs.name + assert provider.adapter == :openid_connect + + assert provider.adapter_config == %{ + "client_id" => provider_attrs.adapter_config["client_id"], + "client_secret" => provider_attrs.adapter_config["client_secret"], + "discovery_document_uri" => provider_attrs.adapter_config["discovery_document_uri"], + "scope" => provider_attrs.adapter_config["scope"], + "response_type" => "code" + } + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + provider: provider, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + adapter_config_attrs = Map.drop(adapter_config_attrs, ["response_type"]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :openid_connect, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/edit") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + validate_change(form, %{provider: %{name: String.duplicate("a", 256)}}, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/new_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/new_test.exs new file mode 100644 index 000000000..60bd87cbe --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/new_test.exs @@ -0,0 +1,142 @@ +defmodule Web.Auth.Settings.IdentityProviders.OpenIDConnect.NewTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor) + + %{ + account: account, + actor: actor, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/openid_connect/new") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider creation form", %{ + account: account, + identity: identity, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/new") + + form = form(lv, "form") + + assert find_inputs(form) == [ + "provider[adapter_config][_persistent_id]", + "provider[adapter_config][client_id]", + "provider[adapter_config][client_secret]", + "provider[adapter_config][discovery_document_uri]", + "provider[adapter_config][response_type]", + "provider[adapter_config][scope]", + "provider[name]" + ] + end + + test "creates a new provider on valid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + adapter_config_attrs = Map.drop(adapter_config_attrs, ["response_type"]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :openid_connect, + adapter_config: adapter_config_attrs + ) + + bypass = Bypass.open() + Bypass.down(bypass) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/new") + + form = + lv + |> form("form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + result = render_submit(form) + assert provider = Domain.Repo.get_by(Domain.Auth.Provider, name: provider_attrs.name) + + assert result == + {:error, + {:redirect, + %{ + to: + ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}/redirect" + }}} + + assert provider.name == provider_attrs.name + assert provider.adapter == :openid_connect + + assert provider.adapter_config == %{ + "client_id" => provider_attrs.adapter_config["client_id"], + "client_secret" => provider_attrs.adapter_config["client_secret"], + "discovery_document_uri" => provider_attrs.adapter_config["discovery_document_uri"], + "scope" => provider_attrs.adapter_config["scope"], + "response_type" => "code" + } + end + + test "renders changeset errors on invalid attrs", %{ + account: account, + identity: identity, + conn: conn + } do + {_bypass, [adapter_config_attrs]} = AuthFixtures.start_openid_providers(["google"]) + adapter_config_attrs = Map.drop(adapter_config_attrs, ["response_type"]) + + provider_attrs = + AuthFixtures.provider_attrs( + adapter: :openid_connect, + adapter_config: adapter_config_attrs + ) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/new") + + form = + form(lv, "form", + provider: %{ + name: provider_attrs.name, + adapter_config: provider_attrs.adapter_config + } + ) + + validate_change(form, %{provider: %{name: String.duplicate("a", 256)}}, fn form, _html -> + assert form_validation_errors(form) == %{ + "provider[name]" => ["should be at most 255 character(s)"] + } + end) + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/show_test.exs new file mode 100644 index 000000000..2f9026f5f --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/openid_connect/show_test.exs @@ -0,0 +1,119 @@ +defmodule Web.Auth.Settings.IdentityProviders.OpenIDConnect.ShowTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + + {provider, bypass} = + AuthFixtures.start_openid_providers(["google"]) + |> AuthFixtures.create_openid_connect_provider(account: account) + + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + bypass: bypass + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider details", %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + conn: conn, + bypass: bypass + } do + inserted_at = Cldr.DateTime.to_string!(provider.inserted_at, Web.CLDR, format: :short) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/openid_connect/#{provider}") + + assert has_element?(lv, "a", "Edit Identity Provider") + + active = + lv + |> element("table") + |> render() + + assert table_to_text(active) == [ + [provider.name], + ["Active"], + ["OpenID Connect"], + ["code"], + [provider.adapter_config["scope"]], + [provider.adapter_config["client_id"]], + ["http://localhost:#{bypass.port}/.well-known/openid-configuration"], + ["#{inserted_at} by System"] + ] + + disabled = + lv + |> element("button", "Disable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(disabled) == [ + [provider.name], + ["Disabled"], + ["OpenID Connect"], + ["code"], + [provider.adapter_config["scope"]], + [provider.adapter_config["client_id"]], + ["http://localhost:#{bypass.port}/.well-known/openid-configuration"], + ["#{inserted_at} by System"] + ] + + provider + |> Ecto.Changeset.change( + created_by: :identity, + created_by_identity_id: identity.id + ) + |> Repo.update!() + + enabled = + lv + |> element("button", "Enable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(enabled) == [ + [provider.name], + ["Active"], + ["OpenID Connect"], + ["code"], + [provider.adapter_config["scope"]], + [provider.adapter_config["client_id"]], + ["http://localhost:#{bypass.port}/.well-known/openid-configuration"], + ["#{inserted_at} by #{actor.name}"] + ] + + assert lv + |> element("button", "Delete Identity Provider") + |> render_click() == + {:error, {:redirect, %{to: ~p"/#{account}/settings/identity_providers"}}} + end +end diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs new file mode 100644 index 000000000..16f78a79a --- /dev/null +++ b/elixir/apps/web/test/web/live/settings/identity_providers/system/show_test.exs @@ -0,0 +1,96 @@ +defmodule Web.Auth.Settings.IdentityProviders.System.ShowTest do + use Web.ConnCase, async: true + alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures} + + setup do + Domain.Config.put_system_env_override(:outbound_email_adapter, Swoosh.Adapters.Postmark) + + account = AccountsFixtures.create_account() + actor = ActorsFixtures.create_actor(type: :account_admin_user, account: account) + provider = AuthFixtures.create_email_provider(account: account) + identity = AuthFixtures.create_identity(account: account, actor: actor, provider: provider) + + %{ + account: account, + actor: actor, + provider: provider, + identity: identity + } + end + + test "redirects to sign in page for unauthorized user", %{ + account: account, + provider: provider, + conn: conn + } do + assert live(conn, ~p"/#{account}/settings/identity_providers/system/#{provider}") == + {:error, + {:redirect, + %{ + to: ~p"/#{account}/sign_in", + flash: %{"error" => "You must log in to access this page."} + }}} + end + + test "renders provider details", %{ + account: account, + actor: actor, + provider: provider, + identity: identity, + conn: conn + } do + inserted_at = Cldr.DateTime.to_string!(provider.inserted_at, Web.CLDR, format: :short) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/system/#{provider}") + + active = + lv + |> element("table") + |> render() + + assert table_to_text(active) == [ + [provider.name], + ["Active"], + ["#{inserted_at} by System"] + ] + + disabled = + lv + |> element("button", "Disable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(disabled) == [ + [provider.name], + ["Disabled"], + ["#{inserted_at} by System"] + ] + + provider + |> Ecto.Changeset.change( + created_by: :identity, + created_by_identity_id: identity.id + ) + |> Repo.update!() + + enabled = + lv + |> element("button", "Enable Identity Provider") + |> render_click() + |> Floki.find("table") + + assert table_to_text(enabled) == [ + [provider.name], + ["Active"], + ["#{inserted_at} by #{actor.name}"] + ] + + assert lv + |> element("button", "Delete Identity Provider") + |> render_click() == + {:error, {:redirect, %{to: ~p"/#{account}/settings/identity_providers"}}} + end +end diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 07b77c266..316b644e0 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -49,6 +49,10 @@ config :domain, Domain.Auth, key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5S1", salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDej1" +config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient, + endpoint: "https://admin.googleapis.com", + finch_transport_opts: [] + ############################### ##### Web ##################### ############################### @@ -147,6 +151,9 @@ config :domain, config :openid_connect, finch_transport_opts: [] +config :ex_cldr, + default_locale: "en" + config :mime, :types, %{ "application/xml" => ["xml"] } diff --git a/elixir/config/dev.exs b/elixir/config/dev.exs index fd0474434..f847560bc 100644 --- a/elixir/config/dev.exs +++ b/elixir/config/dev.exs @@ -28,12 +28,14 @@ config :web, Web.Endpoint, ], live_reload: [ patterns: [ + ~r"apps/config/.*(exs)$", ~r"apps/domain/lib/domain/.*(ex|eex|heex)$", ~r"apps/web/priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"apps/web/priv/gettext/.*(po)$", ~r"apps/web/lib/web/.*(ex|eex|heex)$" ] ], + reloadable_apps: [:domain, :web], server: true root_path = diff --git a/elixir/config/runtime.exs b/elixir/config/runtime.exs index 7d84dc9de..f1ef799d2 100644 --- a/elixir/config/runtime.exs +++ b/elixir/config/runtime.exs @@ -48,6 +48,9 @@ if config_env() == :prod do key_base: compile_config!(:auth_token_key_base), salt: compile_config!(:auth_token_salt) + config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient, + finch_transport_opts: compile_config!(:http_client_ssl_opts) + ############################### ##### Web ##################### ############################### diff --git a/elixir/mix.lock b/elixir/mix.lock index 093e2f2a2..8ee026f7b 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -8,6 +8,7 @@ "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "chatterbox": {:hex, :ts_chatterbox, "0.13.0", "6f059d97bcaa758b8ea6fffe2b3b81362bd06b639d3ea2bb088335511d691ebf", [:rebar3], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "b93d19104d86af0b3f2566c4cba2a57d2e06d103728246ba1ac6c3c0ff010aa7"}, "cidr": {:git, "https://github.com/firezone/cidr-elixir.git", "a32125127a7910f476734f45391ba6d37036ee11", []}, + "cldr_utils": {:hex, :cldr_utils, "2.24.1", "5ff8c8c55f96666228827bcf85a23d632022def200566346545d01d15e4c30dc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "1820300531b5b849d0bc468e5a87cd64f8f2c5191916f548cbe69b2efc203780"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, @@ -19,6 +20,7 @@ "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, + "digital_token": {:hex, :digital_token, "0.6.0", "13e6de581f0b1f6c686f7c7d12ab11a84a7b22fa79adeb4b50eec1a2d278d258", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "2455d626e7c61a128b02a4a8caddb092548c3eb613ac6f6a85e4cbb6caddc4d1"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, @@ -26,6 +28,11 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "esaml": {:git, "https://github.com/firezone/esaml.git", "4294a3ac5262582144e117c10a1537287b6c1fe8", []}, "esbuild": {:hex, :esbuild, "0.7.1", "fa0947e8c3c3c2f86c9bf7e791a0a385007ccd42b86885e8e893bdb6631f5169", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "66661cdf70b1378ee4dc16573fcee67750b59761b2605a0207c267ab9d19f13c"}, + "ex_cldr": {:hex, :ex_cldr, "2.37.2", "c45041534ec60af367c4c1af02a608576118044fe3c441c782fd424061d6b517", [:mix], [{:cldr_utils, "~> 2.21", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "c8467b1d5080716ace6621703b6656cb2f9545572a54b341da900791a0cf92ba"}, + "ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.22.1", "3e5150f1fe7698e0fa118aeedcca1b5920d0a552bc40c81cf65ca9b0a4ea4cc3", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_numbers, "~> 2.31", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.16", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e7408cd9e8318b2ef93b76728e84484ddc3ea6d7c894fbc811c54122a7140169"}, + "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.15.0", "aadd34e91cfac7ef6b03fe8f47f8c6fa8c5daf3f89b5d9fee64ec545ded839cf", [:mix], [{:ex_cldr, "~> 2.34", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "0521316396c66877a2d636219767560bb2397c583341fcb154ecf9f3000e6ff8"}, + "ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.13.3", "bd01c75f017b3a024d0d4c189f2ee0573c15023e98ae16759228a7b57f9414bc", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr, "~> 2.36", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_calendars, "~> 1.18", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.28", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f5b2216189bd9118bb2e5c1abd48f95f48b2eac954fe0e53370806d23b1641ac"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.31.2", "e27a457d594aefd1981094178f95c2efbd0f69c4a0c649c5f4cf5c97e264f310", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.37", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, ">= 2.14.2", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "828c0f39df6cc64bb7c586d7d302321322bc65c5d1d5d6115f632c3711d218b7"}, "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "file_size": {:hex, :file_size, "3.0.1", "ad447a69442a82fc701765a73992d7b1110136fa0d4a9d3190ea685d60034dcd", [:mix], [{:decimal, ">= 1.0.0 and < 3.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:number, "~> 1.0", [hex: :number, repo: "hexpm", optional: false]}], "hexpm", "64dd665bc37920480c249785788265f5d42e98830d757c6a477b3246703b8e20"},