Actor groups and group sync helpers (#1727)

This commit is contained in:
Andrew Dryga
2023-07-31 16:22:40 -06:00
committed by GitHub
parent 17dfdb63d4
commit fe06d2e42d
168 changed files with 8468 additions and 2163 deletions

View File

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

View File

@@ -0,0 +1,19 @@
defmodule Domain.Accounts.Authorizer do
use Domain.Auth.Authorizer
alias Domain.Accounts.Account
def view_accounts_permission, do: build(Account, :view)
@impl Domain.Auth.Authorizer
def list_permissions_for_role(_) do
[view_accounts_permission()]
end
@impl Domain.Auth.Authorizer
def for_subject(queryable, %Subject{} = subject) do
cond do
has_permission?(subject, view_accounts_permission()) ->
Account.Query.by_id(queryable, subject.account.id)
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
defmodule Domain.Actors.Group do
use Domain, :schema
schema "actor_groups" do
field :name, :string
# Those fields will be set for groups we synced from IdP's
belongs_to :provider, Domain.Auth.Provider
field :provider_identifier, :string
has_many :memberships, Domain.Actors.Membership, on_replace: :delete
has_many :actors, through: [:memberships, :actor]
belongs_to :account, Domain.Accounts.Account
field :deleted_at, :utc_datetime_usec
timestamps()
end
end

View File

@@ -0,0 +1,58 @@
defmodule Domain.Actors.Group.Changeset do
use Domain, :changeset
alias Domain.{Auth, Accounts}
alias Domain.Actors
@fields ~w[name]a
def upsert_conflict_target do
{:unsafe_fragment,
"(account_id, provider_id, provider_identifier) " <>
"WHERE deleted_at IS NULL AND provider_id IS NOT NULL AND provider_identifier IS NOT NULL"}
end
# We do not update the `name` field because we allow to manually override it in the UI
# for usability reasons when the provider uses group names that can make people confused
def upsert_on_conflict, do: {:replace, (@fields -- ~w[name]a) ++ ~w[updated_at]a}
def create_changeset(%Accounts.Account{} = account, attrs) do
%Actors.Group{account_id: account.id}
|> changeset(attrs)
|> validate_length(:name, min: 1, max: 64)
|> cast_assoc(:memberships,
with: &Actors.Membership.Changeset.group_changeset(account.id, &1, &2)
)
end
def create_changeset(%Auth.Provider{} = provider, provider_identifier, attrs) do
%Actors.Group{}
|> changeset(attrs)
|> put_change(:provider_id, provider.id)
|> put_change(:provider_identifier, provider_identifier)
|> cast_assoc(:memberships,
with: &Actors.Membership.Changeset.group_changeset(provider.account_id, &1, &2)
)
end
def update_changeset(%Actors.Group{} = group, attrs) do
changeset(group, attrs)
|> validate_length(:name, min: 1, max: 64)
|> cast_assoc(:memberships,
with: &Actors.Membership.Changeset.group_changeset(group.account_id, &1, &2)
)
end
defp changeset(group, attrs) do
group
|> cast(attrs, @fields)
|> validate_required(@fields)
|> trim_change(:name)
|> unique_constraint(:name, name: :actor_groups_account_id_name_index)
end
def delete_changeset(%Actors.Group{} = group) do
group
|> change()
|> put_default_value(:deleted_at, DateTime.utc_now())
end
end

View File

@@ -0,0 +1,38 @@
defmodule Domain.Actors.Group.Query do
use Domain, :query
def all do
from(groups in Domain.Actors.Group, as: :groups)
|> where([groups: groups], is_nil(groups.deleted_at))
end
def by_id(queryable \\ all(), id) do
where(queryable, [groups: groups], groups.id == ^id)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [groups: groups], groups.account_id == ^account_id)
end
def by_provider_id(queryable \\ all(), provider_id) do
where(queryable, [groups: groups], groups.provider_id == ^provider_id)
end
def by_provider_identifier(queryable \\ all(), provider_identifier) do
where(queryable, [groups: groups], groups.provider_identifier == ^provider_identifier)
end
def group_by_provider_id(queryable \\ all()) do
queryable
|> group_by([groups: groups], groups.provider_id)
|> where([groups: groups], not is_nil(groups.provider_id))
|> select([groups: groups], %{
provider_id: groups.provider_id,
count: count(groups.id)
})
end
def lock(queryable \\ all()) do
lock(queryable, "FOR UPDATE")
end
end

View File

@@ -0,0 +1,11 @@
defmodule Domain.Actors.Membership do
use Domain, :schema
@primary_key false
schema "actor_group_memberships" do
belongs_to :group, Domain.Actors.Group, primary_key: true
belongs_to :actor, Domain.Actors.Actor, primary_key: true
belongs_to :account, Domain.Accounts.Account
end
end

View File

@@ -0,0 +1,18 @@
defmodule Domain.Actors.Membership.Changeset do
use Domain, :changeset
def group_changeset(account_id, connection, attrs) do
connection
|> cast(attrs, ~w[actor_id]a)
|> validate_required(~w[actor_id]a)
|> changeset(account_id)
end
defp changeset(changeset, account_id) do
changeset
|> assoc_constraint(:actor)
|> assoc_constraint(:group)
|> assoc_constraint(:account)
|> put_change(:account_id, account_id)
end
end

View File

@@ -0,0 +1,19 @@
defmodule Domain.Actors.Membership.Query do
use Domain, :query
def all do
from(memberships in Domain.Actors.Membership, as: :memberships)
end
def by_actor_id(queryable \\ all(), actor_id) do
where(queryable, [memberships: memberships], memberships.actor_id == ^actor_id)
end
def by_group_id(queryable \\ all(), group_id) do
where(queryable, [memberships: memberships], memberships.group_id == ^group_id)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [memberships: memberships], memberships.account_id == ^account_id)
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
defmodule Domain.Jobs do
@moduledoc """
This module starts all recurrent job handlers defined by a module
in individual processes and supervises them.
"""
use Supervisor
def start_link(module) do
Supervisor.start_link(__MODULE__, module, name: __MODULE__)
end
def init(module) do
config = module.__config__()
children =
Enum.flat_map(module.__handlers__(), fn {name, interval} ->
handler_config = Keyword.get(config, name, [])
if Keyword.get(handler_config, :enabled, true) do
[
Supervisor.child_spec(
{Domain.Jobs.Executors.Global, {{module, name}, interval, handler_config}},
id: {module, name}
)
]
else
[]
end
end)
Supervisor.init(children, strategy: :one_for_one)
end
end

View File

@@ -0,0 +1,164 @@
defmodule Domain.Jobs.Executors.Global do
@moduledoc """
This module is an abstraction on top of a GenServer that executes a callback function
on a given interval on a globally unique process in an Erlang cluster.
It is mainly designed to run recurrent jobs that poll the database using a filter which
prevents duplicate execution, eg:
SELECT *
FROM my_table
WHERE processed_at IS NULL
AND processing_cancelled_at IS NULL
LIMIT 100;
you can also keep manual track of retry attempts like so:
UPDATE my_table
SET processing_attempts_count = processing_attempts_count + 1
WHERE processed_at IS NULL
AND processing_cancelled_at IS NULL
AND processing_attempts_count < 3
LIMIT 100
RETURNING *;
and then updates the processing flag on job is completed:
UPDATE my_table SET processed_at = NOW() WHERE id = ?;
it is also recommended to cancel jobs to prevent them from being executed indefinitely:
UPDATE my_table SET processing_cancelled_at = NOW() WHERE id = ?;
Even though this does not provide a fully fledged job queue, it is a good enough solution
for many use cases like refreshing tokens, deactivating users and even dispatching
emails, while keeping the code simple, company-owned, maintainable and easy to reason about.
## Design Limitations
1. The interval is not guaranteed to be precise. The timer starts after the execution is
finished, so next tick is always delayed by the execution time.
2. Because we don't persist the state the interval will be reset on every restart (eg. during deployment, or
crash loops in your supervision tree), so the interval should not be too big.
3. The jobs must be idempotent. Callback is executed at least once in an erlang cluster and in the given interval,
for example if you restart the cluster - the job execution can be repeated. That's why we don't have helpers
that set interval in hours or more.
"""
use GenServer
require Logger
def start_link({{module, function}, interval, config}) do
GenServer.start_link(__MODULE__, {{module, function}, interval, config})
end
@impl true
def init({{module, function}, interval, config}) do
name = global_name(module, function)
# `random_notify_name` is used to avoid name conflicts in a cluster during deployments and
# network splits, it randomly selects one of the duplicate pids for registration,
# and sends the message {global_name_conflict, Name} to the other pid so that they stop
# trying to claim job queue leadership.
with :no <- :global.register_name(name, self(), &:global.random_notify_name/3),
pid when is_pid(pid) <- :global.whereis_name(name) do
# we monitor the leader process so that we start a race to become a new leader when it's down
monitor_ref = Process.monitor(pid)
{:ok, {{{module, function}, interval, config}, {:fallback, pid, monitor_ref}}, :hibernate}
else
:yes ->
Logger.debug("Recurrent job will be handled on this node",
module: module,
function: function
)
initial_delay = Keyword.get(config, :initial_delay, 0)
{:ok, {{{module, function}, interval, config}, :leader}, initial_delay}
:undefined ->
Logger.warning("Recurrent job leader exists but is not yet available",
module: module,
function: function
)
_timer_ref = :timer.sleep(100)
init(module)
end
end
@impl true
def handle_info(:timeout, {{{_module, _name}, interval, _config}, :leader} = state) do
:ok = schedule_tick(interval)
{:noreply, state}
end
@impl true
def handle_info(
{:global_name_conflict, {__MODULE__, module, function}},
{{{module, function}, interval, config}, _leader_or_fallback} = state
) do
name = global_name(module, function)
with pid when is_pid(pid) <- :global.whereis_name(name) do
monitor_ref = Process.monitor(pid)
state = {{{module, function}, interval, config}, {:fallback, pid, monitor_ref}}
{:noreply, state, :hibernate}
else
:undefined ->
Logger.warning("Recurrent job name conflict",
module: module,
function: function
)
_timer_ref = :timer.sleep(100)
handle_info({:global_name_conflict, module}, state)
end
end
def handle_info(
{:DOWN, _ref, :process, _pid, reason},
{{{module, function}, interval, config}, {:fallback, pid, _monitor_ref}}
) do
# Solves the "Retry Storm" antipattern
backoff_with_jitter = :rand.uniform(200) - 1
_timer_ref = :timer.sleep(backoff_with_jitter)
Logger.info("Recurrent job leader is down",
module: module,
function: function,
leader_pid: inspect(pid),
leader_exit_reason: inspect(reason, pretty: true)
)
case init({{module, function}, interval, config}) do
{:ok, state, :hibernate} -> {:noreply, state, :hibernate}
{:ok, state, _initial_delay} -> {:noreply, state, 0}
end
end
@impl true
def handle_info(:tick, {_definition, {:fallback, _pid, _monitor_ref}} = state) do
{:noreply, state}
end
def handle_info(:tick, {{{module, function}, interval, config}, :leader} = state) do
:ok = execute_handler(module, function, config)
:ok = schedule_tick(interval)
{:noreply, state}
end
# tick is scheduled by using a timeout message instead of `:timer.send_interval/2`,
# because we don't want jobs to overlap if they take too long to execute
defp schedule_tick(interval) do
_ = Process.send_after(self(), :tick, interval)
:ok
end
defp execute_handler(module, function, config) do
_ = apply(module, function, [config])
:ok
end
defp global_name(module, function), do: {__MODULE__, module, function}
end

View File

@@ -0,0 +1,56 @@
defmodule Domain.Jobs.Recurrent do
@doc """
This module provides a DSL to define recurrent jobs that run on an time interval basis.
"""
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
import Domain.Jobs.Recurrent
# Accumulate handlers and define a `__handlers__/0` function to list them
@before_compile Domain.Jobs.Recurrent
Module.register_attribute(__MODULE__, :handlers, accumulate: true)
# Will read the config from the application environment
@otp_app Keyword.fetch!(opts, :otp_app)
@spec __config__() :: Keyword.t()
def __config__, do: Domain.Config.get_env(@otp_app, __MODULE__, [])
end
end
defmacro __before_compile__(_env) do
quote do
@spec __handlers__() :: [{atom(), pos_integer()}]
def __handlers__, do: @handlers
end
end
@doc """
Defines a code to execute every `interval` milliseconds.
Is it recommended to use `seconds/1`, `minutes/1` macros to define the interval.
Behind the hood it defines a function `execute(name, interval, do: ..)` and adds it's name to the
module attribute.
"""
defmacro every(interval, name, do: block) do
quote do
@handlers {unquote(name), unquote(interval)}
def unquote(name)(unquote(Macro.var(:_config, nil))), do: unquote(block)
end
end
defmacro every(interval, name, config, do: block) do
quote do
@handlers {unquote(name), unquote(interval)}
def unquote(name)(unquote(config)), do: unquote(block)
end
end
def seconds(num) do
:timer.seconds(num)
end
def minutes(num) do
:timer.minutes(num)
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}."
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
defmodule Domain.Jobs.RecurrentTest do
use ExUnit.Case, async: true
import Domain.Jobs.Recurrent
defmodule TestDefinition do
use Domain.Jobs.Recurrent, otp_app: :domain
require Logger
every seconds(1), :second_test, config do
send(config[:test_pid], :executed)
end
every minutes(5), :minute_test do
:ok
end
end
describe "seconds/1" do
test "converts seconds to milliseconds" do
assert seconds(1) == 1_000
assert seconds(13) == 13_000
end
end
describe "minutes/1" do
test "converts minutes to milliseconds" do
assert minutes(1) == 60_000
assert minutes(13) == 780_000
end
end
test "defines callbacks" do
assert length(TestDefinition.__handlers__()) == 2
assert {:minute_test, 300_000} in TestDefinition.__handlers__()
assert {:second_test, 1000} in TestDefinition.__handlers__()
assert TestDefinition.minute_test(test_pid: self()) == :ok
assert TestDefinition.second_test(test_pid: self()) == :executed
assert_receive :executed
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,45 @@ import StatusPage from "../vendor/status_page"
let Hooks = {}
// Copy to clipboard
Hooks.Copy = {
mounted() {
this.el.addEventListener("click", (ev) => {
ev.preventDefault();
let text = ev.currentTarget.querySelector("[data-copy]").innerHTML.trim();
let cl = ev.currentTarget.querySelector("[data-icon]").classList
navigator.clipboard.writeText(text).then(() => {
cl.add("hero-clipboard-document-check");
cl.add("text-green-500");
cl.remove("hero-clipboard-document");
cl.remove("text-gray-500");
})
});
},
}
// Update status indicator when sidebar is mounted or updated
let statusIndicatorClassNames = {
none: "bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-green-900 dark:text-green-300",
minor:
"bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-yellow-900 dark:text-yellow-300",
major:
"bg-orange-100 text-orange-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-orange-900 dark:text-orange-300",
critical:
"bg-red-100 text-red-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300",
none: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
minor: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
major: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
}
const statusUpdater = function () {
const self = this
const sp = new StatusPage.page({ page: "firezone" })
sp.summary({
success: function (data) {
self.el.innerHTML = `<span class="${
statusIndicatorClassNames[data.status.indicator]
}">${data.status.description}</span>`
self.el.innerHTML = `
<span class="text-xs font-medium mr-2 px-2.5 py-0.5 rounded ${statusIndicatorClassNames[data.status.indicator]}">
${data.status.description}
</span>
`
},
error: function (data) {
console.error("An error occurred while fetching status page data")
@@ -27,6 +48,7 @@ const statusUpdater = function () {
},
})
}
Hooks.StatusPage = {
mounted: statusUpdater,
updated: statusUpdater,

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
defmodule Web.CLDR do
use Cldr,
locales: ["en"],
providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime]
end

View File

@@ -11,7 +11,6 @@ defmodule Web.CoreComponents do
"""
use Phoenix.Component
use Web, :verified_routes
import Web.Gettext
alias Phoenix.LiveView.JS
def logo(assigns) do
@@ -48,15 +47,35 @@ defmodule Web.CoreComponents do
## Examples
<.code_block>
<.code_block id="foo">
The lazy brown fox jumped over the quick dog.
</.code_block>
"""
attr :id, :string, required: true
attr :class, :string, default: ""
slot :inner_block, required: true
def code_block(assigns) do
~H"""
<pre class="p-4 overflow-x-auto bg-gray-50 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded-lg">
<code class="whitespace-pre-line"><%= render_slot(@inner_block) %></code>
</pre>
<code id={@id} phx-hook="Copy" class={[~w[
rounded-lg
text-sm text-left sm:text-base text-white
inline-flex items-center
space-x-4 p-4 pl-6
bg-gray-800
relative
], @class]}>
<span class="overflow-x-auto" data-copy>
<%= render_slot(@inner_block) %>
</span>
<.icon name="hero-clipboard-document" data-icon class={~w[
absolute bottom-1 right-1
h-5 w-5
transition
text-gray-500 group-hover:text-white
]} />
</code>
"""
end
@@ -132,13 +151,7 @@ defmodule Web.CoreComponents do
## Examples
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/"},
%{label: "Gateways", path: ~p"/gateways"}
]} />
</:breadcrumbs>
<.section>
<:title>
All gateways
</:title>
@@ -147,72 +160,28 @@ defmodule Web.CoreComponents do
Deploy gateway
</.add_button>
</:actions>
</.section_header>
</.section>
"""
slot :breadcrumbs, required: false, doc: "Breadcrumb links"
slot :title, required: true, doc: "Title of the section"
slot :actions, required: false, doc: "Buttons or other action elements"
def section_header(assigns) do
def header(assigns) do
~H"""
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<%= render_slot(@breadcrumbs) %>
<div class="flex justify-between items-center">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">
<%= render_slot(@title) %>
</h1>
<%= render_slot(@actions) %>
<div class="inline-flex justify-between items-center space-x-2">
<%= render_slot(@actions) %>
</div>
</div>
</div>
</div>
"""
end
@doc """
Render a button group.
"""
slot :first, required: true, doc: "First button"
slot :middle, required: false, doc: "Middle button(s)"
slot :last, required: true, doc: "Last button"
def button_group(assigns) do
~H"""
<div class="inline-flex rounded-md shadow-sm" role="group">
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600
dark:focus:ring-blue-500 dark:focus:text-white
]}>
<%= render_slot(@first) %>
</button>
<%= for middle <- @middle do %>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b
border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(middle) %>
</button>
<% end %>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(@last) %>
</button>
</div>
"""
end
@doc """
Renders a paginator bar.
@@ -221,7 +190,7 @@ defmodule Web.CoreComponents do
<.paginator
page={5}
total_pages={100}
collection_base_path={~p"/users"}/>
collection_base_path={~p"/actors"}/>
"""
attr :page, :integer, required: true, doc: "Current page"
attr :total_pages, :integer, required: true, doc: "Total number of pages"
@@ -310,117 +279,6 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders navigation breadcrumbs.
## Examples
<.breadcrumbs entries={[%{label: "Home", path: ~p"/"}]}/>
"""
attr :entries, :list, default: [], doc: "List of breadcrumbs"
def breadcrumbs(assigns) do
~H"""
<div class="col-span-full mb-4 xl:mb-2">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-2">
<li :for={entry <- @entries} class="inline-flex items-center">
<%= if entry.label == "Home" do %>
<.link
navigate={entry.path}
class="inline-flex items-center text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
<.icon name="hero-home-solid" class="w-4 h-4 mr-2" />
<%= entry.label %>
</.link>
<% else %>
<div class="flex items-center text-gray-700 dark:text-gray-300">
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
<.link
navigate={entry.path}
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
>
<%= entry.label %>
</.link>
</div>
<% end %>
</li>
</ol>
</nav>
</div>
"""
end
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
This is a modal.
</.modal>
JS commands may be passed to the `:on_cancel` to configure
the closing/cancel event, for example:
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
This is another modal.
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
def modal(assigns) do
~H"""
<div
id={@id}
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden"
>
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
<div
class="fixed inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div class="flex min-h-full items-center justify-center">
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
>
<div class="absolute top-6 right-5">
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label={gettext("close")}
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
@doc """
Renders flash notices.
@@ -504,11 +362,12 @@ defmodule Web.CoreComponents do
@doc """
Generates a generic error message.
"""
attr :rest, :global
slot :inner_block, required: true
def error(assigns) do
~H"""
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden" {@rest}>
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= render_slot(@inner_block) %>
</p>
@@ -516,27 +375,30 @@ defmodule Web.CoreComponents do
end
@doc """
Renders a header with title.
Generates an error message for a form where it's not related to a specific field but rather to the form itself,
eg. when there is an internal error during API call or one fields not rendered as a form field is invalid.
### Examples
<.base_error form={@form} field={:base} />
"""
attr :class, :string, default: nil
attr :form, :any, required: true, doc: "the form"
attr :field, :atom, doc: "field name"
attr :rest, :global
slot :inner_block, required: true
slot :subtitle
slot :actions
def base_error(assigns) do
assigns = assign_new(assigns, :error, fn -> assigns.form.errors[assigns.field] end)
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %>
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %>
</p>
</div>
<div class="flex-none"><%= render_slot(@actions) %></div>
</header>
<p
:if={@error}
data-validation-error-for={"#{@form.id}[#{@field}]"}
class="mt-3 mb-3 flex gap-3 text-m leading-6 text-rose-600 phx-no-feedback:hidden"
{@rest}
>
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= translate_error(@error) %>
</p>
"""
end
@@ -567,30 +429,6 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts</.back>
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
def back(assigns) do
~H"""
<div class="mt-16">
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
<%= render_slot(@inner_block) %>
</.link>
</div>
"""
end
@doc """
Renders a [Hero Icon](https://heroicons.com).
@@ -611,10 +449,11 @@ defmodule Web.CoreComponents do
"""
attr :name, :string, required: true
attr :class, :string, default: nil
attr :rest, :global
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
<span class={[@name, @class]} {@rest} />
"""
end
@@ -686,7 +525,7 @@ defmodule Web.CoreComponents do
~H"""
<div class="absolute bottom-0 left-0 justify-left p-4 space-x-4 w-full lg:flex bg-white dark:bg-gray-800 z-20">
<.link href="https://firezone.statuspage.io" class="text-xs hover:underline">
<span id="status-page-widget" phx-hook="StatusPage" />
<span id="status-page-widget" phx-update="ignore" phx-hook="StatusPage" />
</.link>
</div>
"""
@@ -713,6 +552,94 @@ defmodule Web.CoreComponents do
"""
end
@doc """
Renders datetime field in a format that is suitable for the user's locale.
"""
attr :datetime, DateTime, required: true
attr :format, :atom, default: :short
def datetime(assigns) do
~H"""
<span title={@datetime}>
<%= Cldr.DateTime.to_string!(@datetime, Web.CLDR, format: @format) %>
</span>
"""
end
@doc """
Returns a string the represents a relative time for a given Datetime
from the current time or a given base time
"""
attr :datetime, DateTime, required: true
attr :relative_to, DateTime, required: false
def relative_datetime(assigns) do
assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end)
~H"""
<span title={@datetime}>
<%= Cldr.DateTime.Relative.to_string!(@datetime, Web.CLDR, relative_to: @relative_to) %>
</span>
"""
end
@doc """
Renders username
"""
attr :schema, :map, required: true
def owner(assigns) do
case assigns.schema.created_by do
:system ->
~H"""
<span>
System
</span>
"""
:identity ->
~H"""
<.link
class="text-blue-600 hover:underline"
navigate={~p"/#{@schema.account_id}/actors/#{@schema.created_by_identity.actor.id}"}
>
<%= assigns.schema.created_by_identity.actor.name %>
</.link>
"""
end
end
@doc """
Helps to pluralize a word based on a cardinal number.
Cardinal numbers indicate an amount—how many of something we have: one, two, three, four, five.
Typically for English you want to set `one` and `other` options. The `other` option is used for all
other numbers that are not `one`. For example, if you want to pluralize the word "file" you would
set `one` to "file" and `other` to "files".
"""
attr :number, :integer, required: true
attr :zero, :string, required: false
attr :one, :string, required: false
attr :two, :string, required: false
attr :few, :string, required: false
attr :many, :string, required: false
attr :other, :string, required: true
attr :rest, :global
def cardinal_number(assigns) do
opts = Map.take(assigns, [:zero, :one, :two, :few, :many, :other])
assigns = Map.put(assigns, :opts, opts)
~H"""
<span data-value={@number} {@rest}>
<%= Web.CLDR.Number.Cardinal.pluralize(@number, :en, @opts) %>
</span>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
@@ -736,30 +663,6 @@ defmodule Web.CoreComponents do
)
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-content")
end
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
@doc """
Translates an error message using gettext.
"""
@@ -787,38 +690,4 @@ defmodule Web.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Returns a string the represents a relative time for a given Datetime
from the current time or a given base time
"""
attr :relative, DateTime, required: true
attr :relative_to, DateTime, required: false, default: DateTime.utc_now()
def relative_datetime(assigns) do
# Note: This code was written with the intent to be replace by the following in the future:
# https://github.com/elixir-cldr/cldr_dates_times/blob/main/lib/cldr/datetime/relative.ex
diff = DateTime.diff(assigns[:relative_to], assigns[:relative])
diff_str =
cond do
diff <= -24 * 3600 -> "in #{div(-diff, 24 * 3600)}day(s)"
diff <= -3600 -> "in #{div(-diff, 3600)}hour(s)"
diff <= -60 -> "in #{div(-diff, 60)}minute(s)"
diff <= -5 -> "in #{-diff}seconds"
diff <= 5 -> "now"
diff <= 60 -> "#{diff}seconds ago"
diff <= 3600 -> "#{div(diff, 60)} minute(s) ago"
diff <= 24 * 3600 -> "#{div(diff, 3600)} hour(s) ago"
true -> "#{div(diff, 24 * 3600)} day(s) ago"
end
assigns = assign(assigns, diff_str: diff_str)
~H"""
<span>
<%= @diff_str %>
</span>
"""
end
end

View File

@@ -87,7 +87,7 @@ defmodule Web.FormComponents do
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
</div>
"""
end
@@ -104,7 +104,7 @@ defmodule Web.FormComponents do
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
</div>
"""
end
@@ -124,7 +124,7 @@ defmodule Web.FormComponents do
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
</div>
"""
end
@@ -142,12 +142,13 @@ defmodule Web.FormComponents do
class={[
"bg-gray-50 p-2.5 block w-full rounded-lg border text-gray-900 focus:ring-primary-600 text-sm",
"phx-no-feedback:border-gray-300 phx-no-feedback:focus:border-primary-600",
"disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 disabled:shadow-none",
"border-gray-300 focus:border-primary-600",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
</div>
"""
end
@@ -172,6 +173,49 @@ defmodule Web.FormComponents do
### Buttons ###
@doc """
Render a button group.
"""
slot :first, required: true, doc: "First button"
slot :middle, required: false, doc: "Middle button(s)"
slot :last, required: true, doc: "Last button"
def button_group(assigns) do
~H"""
<div class="inline-flex rounded-md shadow-sm" role="group">
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-l-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600
dark:focus:ring-blue-500 dark:focus:text-white
]}>
<%= render_slot(@first) %>
</button>
<%= for middle <- @middle do %>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b
border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(middle) %>
</button>
<% end %>
<button type="button" class={~w[
px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200
rounded-r-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2
focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-700 dark:border-gray-600
dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-blue-500
dark:focus:text-white
]}>
<%= render_slot(@last) %>
</button>
</div>
"""
end
@doc """
Renders a button.
@@ -182,7 +226,7 @@ defmodule Web.FormComponents do
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)
attr :rest, :global, include: ~w(disabled form name value navigate)
slot :inner_block, required: true
@@ -239,14 +283,15 @@ defmodule Web.FormComponents do
Edit user
</.delete_button>
"""
attr :phx_click, :string, doc: "Action to perform when the button is clicked"
slot :inner_block, required: true
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
def delete_button(assigns) do
~H"""
<button
type="button"
class="text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
{@rest}
>
<!-- XXX: Fix icon for dark mode -->
<!-- <.icon name="hero-trash-solid" class="text-red-600 w-5 h-5 mr-1 -ml-1" /> -->
@@ -260,7 +305,7 @@ defmodule Web.FormComponents do
## Examples
<.add_button navigate={~p"/users/new"}>
<.add_button navigate={~p"/actors/new"}>
Add user
</.add_button>
"""

View File

@@ -84,7 +84,7 @@
<ul class="py-1 text-gray-700 dark:text-gray-300" aria-labelledby="dropdown">
<li>
<a
href={~p"/#{@subject.account.id}/sign_out"}
href={~p"/#{@account}/sign_out"}
class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Sign out
@@ -95,189 +95,45 @@
</div>
</div>
</nav>
<!-- Sidebar -->
<aside
class="fixed top-0 left-0 z-40 w-64 h-screen pt-14 pb-8 transition-transform -translate-x-full bg-white border-r border-gray-200 md:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
aria-label="Sidenav"
id="drawer-navigation"
>
<div class="overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800">
<form action="#" method="GET" class="md:hidden mb-2">
<label for="sidebar-search" class="sr-only">Search</label>
<div class="relative">
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
<svg
class="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
>
</path>
</svg>
</div>
<input
type="text"
name="search"
id="sidebar-search"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full pl-10 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Search"
/>
</div>
</form>
<ul class="space-y-2">
<li>
<.link
navigate={~p"/#{@subject.account}/dashboard"}
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
>
<.icon
name="hero-chart-bar-square-solid"
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
<span class="ml-3">Dashboard</span>
</.link>
</li>
<li>
<button
type="button"
class="flex items-center p-2 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
aria-controls="dropdown-pages"
data-collapse-toggle="dropdown-pages"
>
<.icon
name="hero-user-group-solid"
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
/>
<span class="flex-1 ml-3 text-left whitespace-nowrap">Organization</span>
<.icon
name="hero-chevron-down-solid"
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
/>
</button>
<ul id="dropdown-pages" class="py-2 space-y-2">
<li>
<.link
navigate={~p"/#{@subject.account}/users"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
Users
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/groups"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
Groups
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/devices"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
Devices
</.link>
</li>
</ul>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/gateways"}
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
>
<.icon
name="hero-arrow-left-on-rectangle-solid"
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
<span class="flex-1 ml-3 text-left whitespace-nowrap">Gateways</span>
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/resources"}
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
>
<.icon
name="hero-server-stack-solid"
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
<span class="flex-1 ml-3 text-left whitespace-nowrap">Resources</span>
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/policies"}
class="flex items-center p-2 text-base font-medium text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
>
<.icon
name="hero-shield-check-solid"
class="w-6 h-6 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white"
/>
<span class="flex-1 ml-3 text-left whitespace-nowrap">Policies</span>
</.link>
</li>
<li>
<button
type="button"
class="flex items-center p-2 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
aria-controls="dropdown-settings"
data-collapse-toggle="dropdown-settings"
>
<.icon
name="hero-cog-solid"
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
/>
<span class="flex-1 ml-3 text-left whitespace-nowrap">Settings</span>
<.icon
name="hero-chevron-down-solid"
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
/>
</button>
<ul id="dropdown-settings" class="py-2 space-y-2">
<li>
<.link
navigate={~p"/#{@subject.account}/settings/account"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
Account
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/settings/identity_providers"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
Identity Providers
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/settings/dns"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
DNS
</.link>
</li>
<li>
<.link
navigate={~p"/#{@subject.account}/settings/api_tokens"}
class="flex items-center p-2 pl-11 w-full text-base font-medium text-gray-900 rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700"
>
API
</.link>
</li>
</ul>
</li>
</ul>
</div>
<.status_page_widget />
</aside>
<.sidebar>
<.sidebar_item navigate={~p"/#{@account}/dashboard"} icon="hero-chart-bar-square-solid">
Dashboard
</.sidebar_item>
<.sidebar_item_group id="organization">
<:name>Organization</:name>
<:item navigate={~p"/#{@account}/actors"}>Users</:item>
<:item navigate={~p"/#{@account}/groups"}>Groups</:item>
<:item navigate={~p"/#{@account}/devices"}>Devices</:item>
</.sidebar_item_group>
<.sidebar_item navigate={~p"/#{@account}/gateways"} icon="hero-arrow-left-on-rectangle-solid">
Gateways
</.sidebar_item>
<.sidebar_item navigate={~p"/#{@account}/resources"} icon="hero-server-stack-solid">
Resources
</.sidebar_item>
<.sidebar_item navigate={~p"/#{@account}/policies"} icon="hero-shield-check-solid">
Policies
</.sidebar_item>
<.sidebar_item_group id="settings">
<:name>Settings</:name>
<:item navigate={~p"/#{@account}/settings/account"}>Account</:item>
<:item navigate={~p"/#{@account}/settings/identity_providers"}>Identity Providers</:item>
<:item navigate={~p"/#{@account}/settings/dns"}>DNS</:item>
<:item navigate={~p"/#{@account}/settings/api_tokens"}>API</:item>
</.sidebar_item_group>
<:bottom>
<.status_page_widget />
</:bottom>
</.sidebar>
<main class="md:ml-64 h-auto pt-16">
<%= @inner_content %>

View File

@@ -0,0 +1,177 @@
defmodule Web.NavigationComponents do
use Phoenix.Component
use Web, :verified_routes
import Web.CoreComponents
slot :bottom, required: false
slot :inner_block,
required: true,
doc: "The items for the navigation bar should use `sidebar_item` component."
def sidebar(assigns) do
~H"""
<aside class={~w[
fixed top-0 left-0 z-40
w-64 h-screen
pt-14 pb-8
transition-transform -translate-x-full
bg-white border-r border-gray-200
md:translate-x-0
dark:bg-gray-800 dark:border-gray-700]} aria-label="Sidenav" id="drawer-navigation">
<div class="overflow-y-auto py-5 px-3 h-full bg-white dark:bg-gray-800">
<ul class="space-y-2">
<%= render_slot(@inner_block) %>
</ul>
</div>
<%= render_slot(@bottom) %>
</aside>
"""
end
attr :icon, :string, required: true
attr :navigate, :string, required: true
slot :inner_block, required: true
def sidebar_item(assigns) do
~H"""
<li>
<.link navigate={@navigate} class={~w[
flex items-center p-2
text-base font-medium text-gray-900
rounded-lg
hover:bg-gray-100
dark:text-white dark:hover:bg-gray-700 group]}>
<.icon name={@icon} class={~w[
w-6 h-6
text-gray-500
transition duration-75
group-hover:text-gray-900
dark:text-gray-400 dark:group-hover:text-white]} />
<span class="ml-3"><%= render_slot(@inner_block) %></span>
</.link>
</li>
"""
end
attr :id, :string, required: true, doc: "ID of the nav group container"
# attr :icon, :string, required: true
# attr :navigate, :string, required: true
slot :name, required: true
slot :item, required: true do
attr :navigate, :string, required: true
end
def sidebar_item_group(assigns) do
~H"""
<li>
<button
type="button"
class={~w[
flex items-center p-2 w-full group rounded-lg
text-base font-medium text-gray-900
transition duration-75
hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700]}
aria-controls={"dropdown-#{@id}"}
data-collapse-toggle={"dropdown-#{@id}"}
>
<.icon name="hero-user-group-solid" class={~w[
w-6 h-6 text-gray-500
transition duration-75
group-hover:text-gray-900
dark:text-gray-400 dark:group-hover:text-white]} />
<span class="flex-1 ml-3 text-left whitespace-nowrap"><%= render_slot(@name) %></span>
<.icon name="hero-chevron-down-solid" class={~w[
w-6 h-6 text-gray-500
transition duration-75
group-hover:text-gray-900
dark:text-gray-400 dark:group-hover:text-white]} />
</button>
<ul id={"dropdown-#{@id}"} class="py-2 space-y-2">
<li :for={item <- @item}>
<.link navigate={item.navigate} class={~w[
flex items-center p-2 pl-11 w-full group rounded-lg
text-base font-medium text-gray-900
transition duration-75
hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700]}>
<%= render_slot(item) %>
</.link>
</li>
</ul>
</li>
"""
end
@doc """
Renders breadcrumbs section, for elements `<.breadcrumb />` component should be used.
"""
attr :home_path, :string, required: true, doc: "The path for to the home page for a user."
slot :inner_block, required: true, doc: "Breadcrumb entries"
def breadcrumbs(assigns) do
~H"""
<nav class="p-4 pb-0" class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-2">
<li class="inline-flex items-center">
<.link
navigate={@home_path}
class="inline-flex items-center text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
<.icon name="hero-home-solid" class="w-4 h-4 mr-2" /> Home
</.link>
<%= render_slot(@inner_block) %>
</li>
</ol>
</nav>
"""
end
@doc """
Renders a single breadcrumb entry. should be wrapped in <.breadcrumbs> component.
"""
slot :inner_block, required: true, doc: "The label for the breadcrumb entry."
attr :path, :string, required: true, doc: "The path for the breadcrumb entry."
def breadcrumb(assigns) do
~H"""
<li class="inline-flex items-center">
<div class="flex items-center text-gray-700 dark:text-gray-300">
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
<.link
navigate={@path}
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
>
<%= render_slot(@inner_block) %>
</.link>
</div>
</li>
"""
end
@doc """
Renders a back navigation link.
## Examples
<.back navigate={~p"/posts"}>Back to posts</.back>
"""
attr :navigate, :any, required: true
slot :inner_block, required: true
def back(assigns) do
~H"""
<div class="mt-16">
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
<%= render_slot(@inner_block) %>
</.link>
</div>
"""
end
end

View File

@@ -28,19 +28,13 @@ defmodule Web.AuthController do
}
}) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
provider_identifier,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
{:ok, subject} <- Web.Auth.sign_in(conn, provider, provider_identifier, secret) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
else
{:error, :not_found} ->
@@ -49,6 +43,11 @@ defmodule Web.AuthController do
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id}/sign_in")
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id}")
{:error, _reason} ->
conn
|> put_flash(:userpass_provider_identifier, String.slice(provider_identifier, 0, 160))
@@ -90,19 +89,13 @@ defmodule Web.AuthController do
"secret" => secret
}) do
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
identity_id,
secret,
conn.assigns.user_agent,
conn.remote_ip
) do
{:ok, subject} <- Web.Auth.sign_in(conn, provider, identity_id, secret) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
conn
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
else
{:error, :not_found} ->
@@ -110,6 +103,11 @@ defmodule Web.AuthController do
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id}/sign_in")
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id}")
{:error, _reason} ->
conn
|> put_flash(:error, "The sign in link is invalid or expired.")
@@ -118,7 +116,7 @@ defmodule Web.AuthController do
end
@doc """
This controller redirects user to IdP for authentication while persisting
This controller redirects user to IdP during sign in for authentication while persisting
verification state to prevent various attacks on OpenID Connect.
"""
def redirect_to_idp(conn, %{"account_id" => account_id, "provider_id" => provider_id}) do
@@ -126,15 +124,7 @@ defmodule Web.AuthController do
redirect_url =
url(~p"/#{provider.account_id}/sign_in/providers/#{provider.id}/handle_callback")
{:ok, authorization_url, {state, code_verifier}} =
OpenIDConnect.authorization_uri(provider, redirect_url)
key = state_cookie_key(provider.id)
value = :erlang.term_to_binary({state, code_verifier})
conn
|> put_resp_cookie(key, value, @state_cookie_options)
|> redirect(external: authorization_url)
redirect_to_idp(conn, redirect_url, provider)
else
{:error, :not_found} ->
conn
@@ -143,8 +133,20 @@ defmodule Web.AuthController do
end
end
def redirect_to_idp(%Plug.Conn{} = conn, redirect_url, %Domain.Auth.Provider{} = provider) do
{:ok, authorization_url, {state, code_verifier}} =
OpenIDConnect.authorization_uri(provider, redirect_url)
key = state_cookie_key(provider.id)
value = :erlang.term_to_binary({state, code_verifier})
conn
|> put_resp_cookie(key, value, @state_cookie_options)
|> redirect(external: authorization_url)
end
@doc """
This controller handles IdP redirect back to the Firezone.
This controller handles IdP redirect back to the Firezone when user signs in.
"""
def handle_idp_callback(conn, %{
"account_id" => account_id,
@@ -152,56 +154,56 @@ defmodule Web.AuthController do
"state" => state,
"code" => code
}) do
key = state_cookie_key(provider_id)
with {:ok, code_verifier, conn} <- verify_state_and_fetch_verifier(conn, provider_id, state) do
payload = {
url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"),
code_verifier,
code
}
with {:ok, code_verifier} <- fetch_verified_state(conn, key, state),
{:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
payload =
{
url(~p"/#{account_id}/sign_in/providers/#{provider_id}/handle_callback"),
code_verifier,
code
},
{:ok, subject} <-
Domain.Auth.sign_in(
provider,
payload,
conn.assigns.user_agent,
conn.remote_ip
) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
with {:ok, provider} <- Domain.Auth.fetch_active_provider_by_id(provider_id),
{:ok, subject} <- Web.Auth.sign_in(conn, provider, payload) do
redirect_to = get_session(conn, :user_return_to) || Auth.signed_in_path(subject)
conn
|> delete_resp_cookie(key, @state_cookie_options)
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> redirect(to: redirect_to)
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id}/sign_in")
|> Web.Auth.renew_session()
|> Web.Auth.put_subject_in_session(subject)
|> delete_session(:user_return_to)
|> redirect(to: redirect_to)
else
{:error, :not_found} ->
conn
|> put_flash(:error, "You can not use this method to sign in.")
|> redirect(to: "/#{account_id}/sign_in")
{:error, :invalid_state} ->
{:error, :invalid_actor_type} ->
conn
|> put_flash(:info, "Please use client application to access Firezone.")
|> redirect(to: ~p"/#{account_id}")
{:error, _reason} ->
conn
|> put_flash(:error, "You can not authenticate to this account.")
|> redirect(to: "/#{account_id}/sign_in")
end
else
{:error, :invalid_state, conn} ->
conn
|> put_flash(:error, "Your session has expired, please try again.")
|> redirect(to: "/#{account_id}/sign_in")
{:error, _reason} ->
conn
|> put_flash(:error, "You can not authenticate to this account.")
|> redirect(to: "/#{account_id}/sign_in")
end
end
defp fetch_verified_state(conn, key, state) do
def verify_state_and_fetch_verifier(conn, provider_id, state) do
key = state_cookie_key(provider_id)
conn = fetch_cookies(conn, signed: [key])
with {:ok, encoded_state} <- Map.fetch(conn.cookies, key),
{^state, verifier} <- :erlang.binary_to_term(encoded_state, [:safe]) do
{:ok, verifier}
{persisted_state, persisted_verifier} <- :erlang.binary_to_term(encoded_state, [:safe]),
:ok <- OpenIDConnect.ensure_states_equal(state, persisted_state) do
{:ok, persisted_verifier, delete_resp_cookie(conn, key, @state_cookie_options)}
else
_ -> {:error, :invalid_state}
_ -> {:error, :invalid_state, delete_resp_cookie(conn, key, @state_cookie_options)}
end
end

View File

@@ -33,7 +33,7 @@ defmodule Web.Endpoint do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader, reloadable_apps: [:domain, :web]
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :domain
end

View File

@@ -1,4 +1,4 @@
defmodule Web.Auth.EmailLive do
defmodule Web.Auth.Email do
use Web, {:live_view, layout: {Web.Layouts, :public}}
def render(assigns) do

View File

@@ -1,7 +1,34 @@
defmodule Web.Auth.ProvidersLive do
defmodule Web.Auth.SignIn do
use Web, {:live_view, layout: {Web.Layouts, :public}}
alias Domain.{Auth, Accounts}
def mount(%{"account_id" => account_id}, _session, socket) do
with {:ok, account} <- Accounts.fetch_account_by_id(account_id),
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
providers_by_adapter =
providers
|> Enum.group_by(fn provider ->
parent_adapter =
provider
|> Auth.fetch_provider_capabilities!()
|> Keyword.get(:parent_adapter)
parent_adapter || provider.adapter
end)
|> Map.drop([:token])
{:ok, socket,
temporary_assigns: [
account: account,
providers_by_adapter: providers_by_adapter,
page_title: "Sign in"
]}
else
_other ->
raise Web.LiveErrors.NotFoundError
end
end
def render(assigns) do
~H"""
<section class="bg-gray-50 dark:bg-gray-900">
@@ -148,10 +175,16 @@ defmodule Web.Auth.ProvidersLive do
def openid_connect_button(assigns) do
~H"""
<a
href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider.id}/redirect"}
class="w-full inline-flex items-center justify-center py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-gray-900 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<a href={~p"/#{@provider.account_id}/sign_in/providers/#{@provider.id}/redirect"} class={~w[
w-full inline-flex items-center justify-center py-2.5 px-5
bg-white rounded-lg
text-sm font-medium text-gray-900
focus:outline-none
border border-gray-200
hover:bg-gray-100 hover:text-gray-900
focus:z-10 focus:ring-4 focus:ring-gray-200
dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400
dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700]}>
Log in with <%= @provider.name %>
</a>
"""
@@ -160,32 +193,4 @@ defmodule Web.Auth.ProvidersLive do
def adapter_enabled?(providers_by_adapter, adapter) do
Map.get(providers_by_adapter, adapter, []) != []
end
def mount(%{"account_id" => account_id}, _session, socket) do
with {:ok, account} <- Accounts.fetch_account_by_id(account_id),
{:ok, [_ | _] = providers} <- Auth.list_active_providers_for_account(account) do
{:ok, socket,
temporary_assigns: [
account: account,
providers_by_adapter: Enum.group_by(providers, & &1.adapter),
page_title: "Sign in"
]}
else
{:ok, []} ->
socket =
socket
|> put_flash(:error, "This account is disabled.")
|> redirect(to: ~p"/#{account_id}/")
{:ok, socket}
{:error, :not_found} ->
socket =
socket
|> put_flash(:error, "Account not found.")
|> redirect(to: ~p"/#{account_id}/")
{:ok, socket}
end
end
end

View File

@@ -1,14 +1,10 @@
defmodule Web.DashboardLive do
defmodule Web.Dashboard do
use Web, :live_view
def render(assigns) do
~H"""
<div class="grid grid-cols-1 p-4 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="col-span-full mb-4 xl:mb-2">
<!-- Breadcrumbs -->
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}"}
]} />
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Dashboard</h1>
</div>
</div>

View File

@@ -1,19 +1,16 @@
defmodule Web.DevicesLive.Index do
defmodule Web.Devices.Index do
use Web, :live_view
def render(assigns) do
~H"""
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Devices", path: ~p"/#{@subject.account}/devices"}
]} />
</:breadcrumbs>
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
<.breadcrumb path={~p"/#{@account}/devices"}>Devices</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
All devices
</:title>
</.section_header>
</.header>
<!-- Devices Table -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<div class="flex flex-col md:flex-row items-center justify-between space-y-3 md:space-y-0 md:space-x-4 p-4">
@@ -86,7 +83,7 @@ defmodule Web.DevicesLive.Index do
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
v1.01 Linux
@@ -94,7 +91,7 @@ defmodule Web.DevicesLive.Index do
</th>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
John Doe
@@ -149,7 +146,7 @@ defmodule Web.DevicesLive.Index do
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
v1.01 iOS
@@ -157,7 +154,7 @@ defmodule Web.DevicesLive.Index do
</th>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Steve Johnson
@@ -212,7 +209,7 @@ defmodule Web.DevicesLive.Index do
class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
<.link
navigate={~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
v1.01 macOS
@@ -220,7 +217,7 @@ defmodule Web.DevicesLive.Index do
</th>
<td class="px-4 py-3">
<.link
navigate={~p"/#{@subject.account}/users/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
navigate={~p"/#{@account}/actors/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Steinberg, Gabriel
@@ -272,7 +269,7 @@ defmodule Web.DevicesLive.Index do
</tbody>
</table>
</div>
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/devices"} />
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/devices"} />
</div>
"""
end

View File

@@ -1,23 +1,20 @@
defmodule Web.DevicesLive.Show do
defmodule Web.Devices.Show do
use Web, :live_view
def render(assigns) do
~H"""
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Devices", path: ~p"/#{@subject.account}/devices"},
%{
label: "Jamil's Macbook Pro",
path: ~p"/#{@subject.account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"
}
]} />
</:breadcrumbs>
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
<.breadcrumb path={~p"/#{@account}/devices"}>Devices</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/devices/DF43E951-7DFB-4921-8F7F-BF0F8D31FA89"}>
Jamil's Macbook Pro
</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Device details
</:title>
</.section_header>
</.header>
<!-- Device Details -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
@@ -42,7 +39,7 @@ defmodule Web.DevicesLive.Show do
</th>
<td class="px-6 py-4">
<.link
navigate={~p"/#{@subject.account}/users/55DDA8CB-69A7-48FC-9048-639021C205A2"}
navigate={~p"/#{@account}/actors/55DDA8CB-69A7-48FC-9048-639021C205A2"}
class="text-blue-600 hover:underline"
>
Andrew Dryga
@@ -141,7 +138,7 @@ defmodule Web.DevicesLive.Show do
</table>
</div>
<.section_header>
<.header>
<:title>
Danger zone
</:title>
@@ -150,7 +147,7 @@ defmodule Web.DevicesLive.Show do
Archive
</.delete_button>
</:actions>
</.section_header>
</.header>
"""
end
end

View File

@@ -1,4 +1,4 @@
defmodule Web.GatewaysLive.Edit do
defmodule Web.Gateways.Edit do
use Web, :live_view
alias Domain.Gateways
@@ -10,25 +10,18 @@ defmodule Web.GatewaysLive.Edit do
def render(assigns) do
~H"""
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
%{
label: "#{@gateway.name_suffix}",
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}"
},
%{
label: "Edit",
path: ~p"/#{@subject.account}/gateways/#{@gateway.id}/edit"
}
]} />
</:breadcrumbs>
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}"}>
<%= @gateway.name_suffix %>
</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateways/#{@gateway}/edit"}>Edit</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Editing Gateway <code><%= @gateway.name_suffix %></code>
</:title>
</.section_header>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">

View File

@@ -1,4 +1,4 @@
defmodule Web.GatewaysLive.Index do
defmodule Web.Gateways.Index do
use Web, :live_view
alias Domain.Gateways
@@ -27,22 +27,19 @@ defmodule Web.GatewaysLive.Index do
def render(assigns) do
~H"""
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"}
]} />
</:breadcrumbs>
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
All gateways
</:title>
<:actions>
<.add_button navigate={~p"/#{@subject.account}/gateways/new"}>
<.add_button navigate={~p"/#{@account}/gateways/new"}>
Add Instance Group
</.add_button>
</:actions>
</.section_header>
</.header>
<!-- Gateways Table -->
<div class="bg-white dark:bg-gray-800 overflow-hidden">
<.resource_filter />
@@ -50,7 +47,7 @@ defmodule Web.GatewaysLive.Index do
<:col label="INSTANCE GROUP"></:col>
<:col :let={gateway} label="INSTANCE">
<.link
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
navigate={~p"/#{@account}/gateways/#{gateway.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= gateway.name_suffix %>
@@ -76,7 +73,7 @@ defmodule Web.GatewaysLive.Index do
</:col>
<:action :let={gateway}>
<.link
navigate={~p"/#{@subject.account}/gateways/#{gateway.id}"}
navigate={~p"/#{@account}/gateways/#{gateway.id}"}
class="block py-2 px-4 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
>
Show
@@ -91,7 +88,7 @@ defmodule Web.GatewaysLive.Index do
</a>
</:action>
</.table_with_groups>
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@subject.account}/gateways"} />
<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/gateways"} />
</div>
"""
end

View File

@@ -1,20 +1,18 @@
defmodule Web.GatewaysLive.New do
defmodule Web.Gateways.New do
use Web, :live_view
def render(assigns) do
~H"""
<.section_header>
<:breadcrumbs>
<.breadcrumbs entries={[
%{label: "Home", path: ~p"/#{@subject.account}/dashboard"},
%{label: "Gateways", path: ~p"/#{@subject.account}/gateways"},
%{label: "Add Gateway", path: ~p"/#{@subject.account}/gateways/new"}
]} />
</:breadcrumbs>
<.breadcrumbs home_path={~p"/#{@account}/dashboard"}>
<.breadcrumb path={~p"/#{@account}/gateways"}>Gateways</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/gateways/new"}>Add Gateway</.breadcrumb>
</.breadcrumbs>
<.header>
<:title>
Add a new Gateway
</:title>
</.section_header>
</.header>
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
@@ -40,7 +38,7 @@ defmodule Web.GatewaysLive.New do
</div>
<.tabs id="deployment-instructions">
<:tab id="docker-instructions" label="Docker">
<.code_block>
<.code_block id="code-sample-docker">
docker run -d \
--name=zigbee2mqtt \
--restart=always \
@@ -52,7 +50,7 @@ defmodule Web.GatewaysLive.New do
</.code_block>
</:tab>
<:tab id="systemd-instructions" label="Systemd">
<.code_block>
<.code_block id="code-sample-systemd">
[Unit]
Description=zigbee2mqtt
After=network.target

Some files were not shown because too many files have changed in this diff Show More