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"""
+
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> <%= render_slot(@inner_block) %>
@@ -516,27 +375,30 @@ defmodule Web.CoreComponents do end @doc """ - Renders a header with title. + Generates an error message for a form where it's not related to a specific field but rather to the form itself, + eg. when there is an internal error during API call or one fields not rendered as a form field is invalid. + + ### Examples + + <.base_error form={@form} field={:base} /> """ - attr :class, :string, default: nil + attr :form, :any, required: true, doc: "the form" + attr :field, :atom, doc: "field name" + attr :rest, :global - slot :inner_block, required: true - slot :subtitle - slot :actions + def base_error(assigns) do + assigns = assign_new(assigns, :error, fn -> assigns.form.errors[assigns.field] end) - def header(assigns) do ~H""" -- <%= render_slot(@subtitle) %> -
-+ <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> + <%= translate_error(@error) %> +
""" end @@ -567,30 +429,6 @@ defmodule Web.CoreComponents do """ 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""" -