mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-28 02:18:50 +00:00
Add dynamic/managed groups and default Everyone one (#3346)
After this PR is merged a manual migration will be needed to upsert Everyone group to existing accounts. Closes #2588 *This PR also improves UX around groups:* 1. Group selection now shows their source in dropdowns: <img width="669" alt="Screenshot 2024-02-08 at 18 30 25" src="https://github.com/firezone/firezone/assets/1877644/accb5cf9-1c16-429b-a16f-e63bb0c7930f"> 2. The same is done across other pages which will help in case there is a duplicate group name (eg. manual and synced one): <img width="766" alt="Screenshot 2024-02-08 at 18 31 59" src="https://github.com/firezone/firezone/assets/1877644/f3133ceb-fc9d-4f7a-bfe2-63f81f379c9a"> <img width="1728" alt="Screenshot 2024-02-08 at 18 34 04" src="https://github.com/firezone/firezone/assets/1877644/daa86c7e-8401-418d-b8e5-ddaff31a1834"> <img width="1728" alt="Screenshot 2024-02-08 at 18 34 22" src="https://github.com/firezone/firezone/assets/1877644/5c885d06-0b0d-4385-a06e-8e9c09b85535"> <img width="576" alt="Screenshot 2024-02-08 at 18 34 31" src="https://github.com/firezone/firezone/assets/1877644/86b2020e-7159-4800-a08e-cecf7b0b1798"> 3. A bug was fixed and now we don't show synced groups whenever an actor is created: <img width="662" alt="Screenshot 2024-02-08 at 18 32 22" src="https://github.com/firezone/firezone/assets/1877644/f69efe85-d7ac-412a-b267-9094a8dd9426"> 4. We provide reason why groups are not editable: <img width="591" alt="Screenshot 2024-02-08 at 18 33 29" src="https://github.com/firezone/firezone/assets/1877644/1525d876-1aad-4a17-be38-6a39c4bc7908"> <img width="558" alt="Screenshot 2024-02-08 at 18 33 50" src="https://github.com/firezone/firezone/assets/1877644/92615b97-19a6-4bf9-804d-d0d16c6c2dfe">
This commit is contained in:
@@ -56,6 +56,21 @@ defmodule Domain.Actors do
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: this should be a filter
|
||||
def list_editable_groups(%Auth.Subject{} = subject, opts \\ []) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
{:ok, groups} =
|
||||
Group.Query.not_deleted()
|
||||
|> Group.Query.editable()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
|
||||
{:ok, Repo.preload(groups, preload)}
|
||||
end
|
||||
end
|
||||
|
||||
def peek_group_actors(groups, limit, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
ids = groups |> Enum.map(& &1.id) |> Enum.uniq()
|
||||
@@ -71,10 +86,24 @@ defmodule Domain.Actors do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
ids = actors |> Enum.map(& &1.id) |> Enum.uniq()
|
||||
|
||||
Actor.Query.by_id({:in, ids})
|
||||
|> Actor.Query.preload_few_groups_for_each_actor(limit)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.peek(actors)
|
||||
{:ok, peek} =
|
||||
Actor.Query.by_id({:in, ids})
|
||||
|> Actor.Query.preload_few_groups_for_each_actor(limit)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.peek(actors)
|
||||
|
||||
group_by_ids =
|
||||
Enum.flat_map(peek, fn {_id, %{items: items}} -> items end)
|
||||
|> Repo.preload(:provider)
|
||||
|> Enum.map(&{&1.id, &1})
|
||||
|> Enum.into(%{})
|
||||
|
||||
peek =
|
||||
for {id, %{items: items} = map} <- peek, into: %{} do
|
||||
{id, %{map | items: Enum.map(items, &Map.fetch!(group_by_ids, &1.id))}}
|
||||
end
|
||||
|
||||
{:ok, peek}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,6 +119,19 @@ defmodule Domain.Actors do
|
||||
change_group(%Group{}, attrs)
|
||||
end
|
||||
|
||||
def create_managed_group(%Accounts.Account{} = account, attrs) do
|
||||
changeset = Group.Changeset.create(account, attrs)
|
||||
|
||||
case Repo.insert(changeset) do
|
||||
{:ok, group} ->
|
||||
:ok = broadcast_group_memberships_events(group, changeset)
|
||||
{:ok, group}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def create_group(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
|
||||
changeset = Group.Changeset.create(subject.account, attrs, subject)
|
||||
@@ -107,6 +149,10 @@ defmodule Domain.Actors do
|
||||
|
||||
def change_group(group, attrs \\ %{})
|
||||
|
||||
def change_group(%Group{type: :managed}, _attrs) do
|
||||
raise ArgumentError, "can't change managed groups"
|
||||
end
|
||||
|
||||
def change_group(%Group{provider_id: nil} = group, attrs) do
|
||||
Group.Changeset.update(group, attrs)
|
||||
end
|
||||
@@ -115,6 +161,10 @@ defmodule Domain.Actors do
|
||||
raise ArgumentError, "can't change synced groups"
|
||||
end
|
||||
|
||||
def update_group(%Group{type: :managed}, _attrs, %Auth.Subject{}) do
|
||||
{:error, :managed_group}
|
||||
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.Query.by_id(group.id)
|
||||
@@ -134,6 +184,28 @@ defmodule Domain.Actors do
|
||||
{:error, :synced_group}
|
||||
end
|
||||
|
||||
def update_dynamic_group_memberships(account_id) do
|
||||
Repo.transaction(fn ->
|
||||
Group.Query.by_account_id(account_id)
|
||||
|> Group.Query.by_type({:in, [:dynamic, :managed]})
|
||||
|> Group.Query.lock()
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn group ->
|
||||
changeset =
|
||||
group
|
||||
|> Repo.preload(:memberships)
|
||||
|> Ecto.Changeset.change()
|
||||
|> Group.Changeset.put_dynamic_memberships(account_id)
|
||||
|
||||
{:ok, group} = Repo.update(changeset)
|
||||
|
||||
:ok = broadcast_memberships_events(changeset)
|
||||
|
||||
group
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
def delete_group(%Group{provider_id: nil} = group, %Auth.Subject{} = subject) do
|
||||
queryable = Group.Query.by_id(group.id)
|
||||
|
||||
@@ -218,6 +290,12 @@ defmodule Domain.Actors do
|
||||
def group_synced?(%Group{provider_id: nil}), do: false
|
||||
def group_synced?(%Group{}), do: true
|
||||
|
||||
def group_managed?(%Group{type: :managed}), do: true
|
||||
def group_managed?(%Group{}), do: false
|
||||
|
||||
def group_editable?(%Group{} = group),
|
||||
do: not group_synced?(group) and not group_managed?(group)
|
||||
|
||||
def group_deleted?(%Group{deleted_at: nil}), do: false
|
||||
def group_deleted?(%Group{}), do: true
|
||||
|
||||
@@ -311,10 +389,12 @@ defmodule Domain.Actors do
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn actor ->
|
||||
actor = maybe_preload_not_synced_memberships(actor, attrs)
|
||||
blacklisted_groups = list_blacklisted_groups(attrs)
|
||||
changeset = Actor.Changeset.update(actor, attrs, blacklisted_groups, subject)
|
||||
synced_groups = list_readonly_groups(attrs)
|
||||
changeset = Actor.Changeset.update(actor, attrs, synced_groups, subject)
|
||||
|
||||
after_commit_cb = fn _group -> :ok = broadcast_memberships_events(changeset) end
|
||||
after_commit_cb = fn _group ->
|
||||
:ok = broadcast_memberships_events(changeset)
|
||||
end
|
||||
|
||||
cond do
|
||||
changeset.data.type != :account_admin_user ->
|
||||
@@ -348,7 +428,7 @@ defmodule Domain.Actors do
|
||||
end
|
||||
end
|
||||
|
||||
defp list_blacklisted_groups(attrs) do
|
||||
defp list_readonly_groups(attrs) do
|
||||
(Map.get(attrs, "memberships") || Map.get(attrs, :memberships) || [])
|
||||
|> Enum.flat_map(fn membership ->
|
||||
if group_id = Map.get(membership, "group_id") || Map.get(membership, :group_id) do
|
||||
@@ -363,7 +443,7 @@ defmodule Domain.Actors do
|
||||
|
||||
group_ids ->
|
||||
Group.Query.by_id({:in, group_ids})
|
||||
|> Group.Query.by_not_empty_provider_id()
|
||||
|> Group.Query.not_editable()
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
@@ -408,6 +488,7 @@ defmodule Domain.Actors do
|
||||
|> Membership.Query.returning_all()
|
||||
|> Repo.delete_all()
|
||||
|
||||
{:ok, _groups} = update_dynamic_group_memberships(actor.account_id)
|
||||
:ok = broadcast_membership_removal_events(memberships)
|
||||
{:ok, _tokens} = Tokens.delete_tokens_for(actor, subject)
|
||||
|
||||
@@ -460,7 +541,7 @@ defmodule Domain.Actors do
|
||||
end
|
||||
|
||||
defp broadcast_memberships_events(changeset) do
|
||||
if changeset.valid? and Validator.changed?(changeset, :memberships) do
|
||||
if changeset.valid? and Ecto.Changeset.changed?(changeset, :memberships) do
|
||||
case Ecto.Changeset.apply_action(changeset, :update) do
|
||||
{:ok, %Actor{} = actor} ->
|
||||
broadcast_actor_memberships_events(actor, changeset)
|
||||
|
||||
@@ -10,6 +10,10 @@ defmodule Domain.Actors.Actor.Query do
|
||||
|> where([actors: actors], is_nil(actors.deleted_at))
|
||||
end
|
||||
|
||||
def not_disabled(queryable \\ not_deleted()) do
|
||||
where(queryable, [actors: actors], is_nil(actors.disabled_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ not_deleted(), id)
|
||||
|
||||
def by_id(queryable, {:in, ids}) do
|
||||
@@ -32,10 +36,6 @@ defmodule Domain.Actors.Actor.Query do
|
||||
where(queryable, [actors: actors], actors.type == ^type)
|
||||
end
|
||||
|
||||
def not_disabled(queryable \\ not_deleted()) do
|
||||
where(queryable, [actors: actors], is_nil(actors.disabled_at))
|
||||
end
|
||||
|
||||
def preload_few_groups_for_each_actor(queryable \\ not_deleted(), limit) do
|
||||
queryable
|
||||
|> with_joined_memberships(limit)
|
||||
@@ -48,6 +48,12 @@ defmodule Domain.Actors.Actor.Query do
|
||||
})
|
||||
end
|
||||
|
||||
def select_distinct_ids(queryable) do
|
||||
queryable
|
||||
|> select([actors: actors], actors.id)
|
||||
|> distinct(true)
|
||||
end
|
||||
|
||||
def with_joined_memberships(queryable, limit) do
|
||||
subquery =
|
||||
Domain.Actors.Membership.Query.all()
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule Domain.Actors.Group do
|
||||
|
||||
schema "actor_groups" do
|
||||
field :name, :string
|
||||
field :type, Ecto.Enum, values: ~w[managed dynamic static]a
|
||||
|
||||
# Those fields will be set for groups we synced from IdP's
|
||||
belongs_to :provider, Domain.Auth.Provider
|
||||
@@ -12,12 +13,14 @@ defmodule Domain.Actors.Group do
|
||||
foreign_key: :actor_group_id,
|
||||
where: [deleted_at: nil]
|
||||
|
||||
embeds_many :membership_rules, Domain.Actors.MembershipRule, on_replace: :delete
|
||||
has_many :memberships, Domain.Actors.Membership, on_replace: :delete
|
||||
|
||||
# TODO: where doesn't work on join tables so soft-deleted records will be preloaded,
|
||||
# ref https://github.com/firezone/firezone/issues/2162
|
||||
has_many :actors, through: [:memberships, :actor]
|
||||
|
||||
field :created_by, Ecto.Enum, values: ~w[identity provider]a
|
||||
field :created_by, Ecto.Enum, values: ~w[system identity provider]a
|
||||
belongs_to :created_by_identity, Domain.Auth.Identity
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
@@ -13,21 +13,32 @@ defmodule Domain.Actors.Group.Changeset do
|
||||
|
||||
def create(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do
|
||||
%Actors.Group{memberships: []}
|
||||
|> cast(attrs, ~w[name]a)
|
||||
|> validate_required(~w[name]a)
|
||||
|> cast(attrs, ~w[name type]a)
|
||||
|> validate_required(~w[name type]a)
|
||||
|> validate_inclusion(:type, ~w[dynamic static]a)
|
||||
|> changeset()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> cast_assoc(:memberships,
|
||||
with: &Actors.Membership.Changeset.for_group(account.id, &1, &2)
|
||||
)
|
||||
|> cast_membership_assocs(account.id)
|
||||
|> put_change(:created_by, :identity)
|
||||
|> put_change(:created_by_identity_id, subject.identity.id)
|
||||
end
|
||||
|
||||
def create(%Accounts.Account{} = account, attrs) do
|
||||
%Actors.Group{memberships: []}
|
||||
|> cast(attrs, ~w[name]a)
|
||||
|> validate_required(~w[name]a)
|
||||
|> put_change(:type, :managed)
|
||||
|> changeset()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> cast_membership_assocs(account.id)
|
||||
|> put_change(:created_by, :system)
|
||||
end
|
||||
|
||||
def create(%Auth.Provider{} = provider, attrs) do
|
||||
%Actors.Group{memberships: []}
|
||||
|> cast(attrs, ~w[name provider_identifier]a)
|
||||
|> validate_required(~w[name provider_identifier]a)
|
||||
|> put_change(:type, :static)
|
||||
|> changeset()
|
||||
|> put_change(:provider_id, provider.id)
|
||||
|> put_change(:account_id, provider.account_id)
|
||||
@@ -38,10 +49,9 @@ defmodule Domain.Actors.Group.Changeset do
|
||||
group
|
||||
|> cast(attrs, ~w[name]a)
|
||||
|> validate_required(~w[name]a)
|
||||
|> validate_inclusion(:type, ~w[dynamic static]a)
|
||||
|> changeset()
|
||||
|> cast_assoc(:memberships,
|
||||
with: &Actors.Membership.Changeset.for_group(group.account_id, &1, &2)
|
||||
)
|
||||
|> cast_membership_assocs(group.account_id)
|
||||
end
|
||||
|
||||
defp changeset(changeset) do
|
||||
@@ -50,4 +60,53 @@ defmodule Domain.Actors.Group.Changeset do
|
||||
|> validate_length(:name, min: 1, max: 64)
|
||||
|> unique_constraint(:name, name: :actor_groups_account_id_name_index)
|
||||
end
|
||||
|
||||
defp cast_membership_assocs(changeset, account_id) do
|
||||
case fetch_field(changeset, :type) do
|
||||
{_data_or_changes, :static} ->
|
||||
cast_assoc(changeset, :memberships,
|
||||
with: &Actors.Membership.Changeset.for_group(account_id, &1, &2)
|
||||
)
|
||||
|
||||
{_data_or_changes, type} when type in [:dynamic, :managed] ->
|
||||
changeset
|
||||
|> cast_embed(:membership_rules,
|
||||
with: &Actors.MembershipRule.Changeset.changeset(&1, &2),
|
||||
required: true
|
||||
)
|
||||
|> cast_dynamic_memberships(account_id)
|
||||
|
||||
_other ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_dynamic_memberships(changeset, account_id) do
|
||||
with true <- changeset.valid?,
|
||||
true <- changed?(changeset, :membership_rules) do
|
||||
put_dynamic_memberships(changeset, account_id)
|
||||
else
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
def put_dynamic_memberships(changeset, account_id) do
|
||||
{_data_or_changes, rules} = fetch_field(changeset, :membership_rules)
|
||||
|
||||
rules =
|
||||
Enum.map(rules, fn
|
||||
%Ecto.Changeset{} = changeset -> apply_changes(changeset)
|
||||
schema -> schema
|
||||
end)
|
||||
|
||||
memberships =
|
||||
Auth.list_actor_ids_by_membership_rules(account_id, rules)
|
||||
|> Enum.map(fn actor_id ->
|
||||
Actors.Membership.Changeset.for_group(account_id, %Actors.Membership{}, %{
|
||||
actor_id: actor_id
|
||||
})
|
||||
end)
|
||||
|
||||
put_change(changeset, :memberships, memberships)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,14 @@ defmodule Domain.Actors.Group.Query do
|
||||
|> where([groups: groups], is_nil(groups.deleted_at))
|
||||
end
|
||||
|
||||
def not_editable(queryable \\ not_deleted()) do
|
||||
where(queryable, [groups: groups], not is_nil(groups.provider_id) or groups.type != :static)
|
||||
end
|
||||
|
||||
def editable(queryable \\ not_deleted()) do
|
||||
where(queryable, [groups: groups], is_nil(groups.provider_id) and groups.type == :static)
|
||||
end
|
||||
|
||||
def by_id(queryable \\ not_deleted(), id)
|
||||
|
||||
def by_id(queryable, {:in, ids}) do
|
||||
@@ -20,6 +28,16 @@ defmodule Domain.Actors.Group.Query do
|
||||
where(queryable, [groups: groups], groups.id == ^id)
|
||||
end
|
||||
|
||||
def by_type(queryable \\ not_deleted(), type)
|
||||
|
||||
def by_type(queryable, {:in, types}) do
|
||||
where(queryable, [groups: groups], groups.type in ^types)
|
||||
end
|
||||
|
||||
def by_type(queryable, type) do
|
||||
where(queryable, [groups: groups], groups.type == ^type)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ not_deleted(), account_id) do
|
||||
where(queryable, [groups: groups], groups.account_id == ^account_id)
|
||||
end
|
||||
@@ -28,10 +46,6 @@ defmodule Domain.Actors.Group.Query do
|
||||
where(queryable, [groups: groups], groups.provider_id == ^provider_id)
|
||||
end
|
||||
|
||||
def by_not_empty_provider_id(queryable \\ not_deleted()) do
|
||||
where(queryable, [groups: groups], not is_nil(groups.provider_id))
|
||||
end
|
||||
|
||||
def by_provider_identifier(queryable \\ not_deleted(), provider_identifier)
|
||||
|
||||
def by_provider_identifier(queryable, {:in, provider_identifiers}) do
|
||||
|
||||
@@ -77,6 +77,7 @@ defmodule Domain.Actors.Group.Sync do
|
||||
provider_identifiers_to_upsert
|
||||
|> Enum.reduce_while({:ok, []}, fn provider_identifier, {:ok, acc} ->
|
||||
attrs = Map.get(attrs_by_provider_identifier, provider_identifier)
|
||||
attrs = Map.put(attrs, "type", :managed)
|
||||
|
||||
case upsert_group(repo, provider, attrs) do
|
||||
{:ok, group} ->
|
||||
|
||||
12
elixir/apps/domain/lib/domain/actors/membership_rule.ex
Normal file
12
elixir/apps/domain/lib/domain/actors/membership_rule.ex
Normal file
@@ -0,0 +1,12 @@
|
||||
defmodule Domain.Actors.MembershipRule do
|
||||
use Domain, :schema
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
# `true` is a special operator which allows to select all account identities
|
||||
field :operator, Ecto.Enum, values: ~w[true contains does_not_contain is_in is_not_in]a
|
||||
|
||||
field :path, {:array, :string}
|
||||
field :values, {:array, :string}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
defmodule Domain.Actors.MembershipRule.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Actors.MembershipRule
|
||||
|
||||
@fields ~w[operator path values]a
|
||||
|
||||
def changeset(membership_rule \\ %MembershipRule{}, attrs) do
|
||||
membership_rule
|
||||
|> cast(attrs, @fields)
|
||||
|> validate_rule()
|
||||
end
|
||||
|
||||
defp validate_rule(changeset) do
|
||||
case fetch_field(changeset, :operator) do
|
||||
{_data_or_changes, true} ->
|
||||
changeset
|
||||
|> validate_required(~w[operator]a)
|
||||
|> delete_change(:path)
|
||||
|> delete_change(:values)
|
||||
|
||||
{_data_or_changes, operator} when operator in [:contains, :does_not_contain] ->
|
||||
changeset
|
||||
|> validate_required(@fields)
|
||||
|> validate_path()
|
||||
|> validate_length(:values, min: 1, max: 1)
|
||||
|
||||
_other ->
|
||||
changeset
|
||||
|> validate_required(@fields)
|
||||
|> validate_path()
|
||||
|> validate_length(:values, min: 1, max: 32)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_path(changeset) do
|
||||
validate_change(changeset, :path, fn
|
||||
:path, [head | _tail] when head in ["claims", "userinfo"] ->
|
||||
[]
|
||||
|
||||
:path, _path ->
|
||||
["only `claims` or `userinfo` fields are currently supported"]
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -348,6 +348,13 @@ defmodule Domain.Auth do
|
||||
Identity.Sync.sync_provider_identities_multi(provider, attrs_list)
|
||||
end
|
||||
|
||||
def list_actor_ids_by_membership_rules(account_id, membership_rules) do
|
||||
Identity.Query.by_account_id(account_id)
|
||||
|> Identity.Query.by_membership_rules(membership_rules)
|
||||
|> Identity.Query.returning_distinct_actor_ids()
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# used by IdP adapters
|
||||
def upsert_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs) do
|
||||
Identity.Changeset.create_identity(actor, provider, attrs)
|
||||
@@ -359,6 +366,14 @@ defmodule Domain.Auth do
|
||||
on_conflict: {:replace, [:provider_state]},
|
||||
returning: true
|
||||
)
|
||||
|> case do
|
||||
{:ok, identity} ->
|
||||
{:ok, _groups} = Actors.update_dynamic_group_memberships(actor.account_id)
|
||||
{:ok, identity}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def new_identity(%Actors.Actor{} = actor, %Provider{} = provider, attrs \\ %{}) do
|
||||
@@ -386,6 +401,14 @@ defmodule Domain.Auth do
|
||||
Identity.Changeset.create_identity(actor, provider, attrs)
|
||||
|> Adapters.identity_changeset(provider)
|
||||
|> Repo.insert()
|
||||
|> case do
|
||||
{:ok, identity} ->
|
||||
{:ok, _groups} = Actors.update_dynamic_group_memberships(account_id)
|
||||
{:ok, identity}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def replace_identity(%Identity{} = identity, attrs, %Subject{} = subject) do
|
||||
@@ -418,6 +441,7 @@ defmodule Domain.Auth do
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{new_identity: identity}} ->
|
||||
{:ok, _groups} = Actors.update_dynamic_group_memberships(identity.account_id)
|
||||
{:ok, identity}
|
||||
|
||||
{:error, _step, error_or_changeset, _effects_so_far} ->
|
||||
@@ -447,6 +471,14 @@ defmodule Domain.Auth do
|
||||
Identity.Changeset.delete_identity(identity)
|
||||
end
|
||||
)
|
||||
|> case do
|
||||
{:ok, identity} ->
|
||||
{:ok, _groups} = Actors.update_dynamic_group_memberships(identity.account_id)
|
||||
{:ok, identity}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -471,6 +503,8 @@ defmodule Domain.Auth do
|
||||
|> Authorizer.for_subject(Identity, subject)
|
||||
|> Repo.update_all(set: [deleted_at: DateTime.utc_now(), provider_state: %{}])
|
||||
|
||||
{:ok, _groups} = Actors.update_dynamic_group_memberships(assoc.account_id)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +26,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace do
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:just_in_time, :custom],
|
||||
provisioners: [:custom],
|
||||
default_provisioner: :custom,
|
||||
parent_adapter: :openid_connect
|
||||
]
|
||||
|
||||
@@ -23,8 +23,8 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
@impl true
|
||||
def capabilities do
|
||||
[
|
||||
provisioners: [:just_in_time, :manual],
|
||||
default_provisioner: :just_in_time,
|
||||
provisioners: [:manual],
|
||||
default_provisioner: :manual,
|
||||
parent_adapter: :openid_connect
|
||||
]
|
||||
end
|
||||
@@ -123,9 +123,10 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
fetch_state(provider, token_params, identifier_claim) do
|
||||
Identity.Query.not_disabled()
|
||||
|> Identity.Query.by_provider_id(provider.id)
|
||||
|> Identity.Query.by_provider_claims(
|
||||
|> maybe_by_provider_claims(
|
||||
provider,
|
||||
provider_identifier,
|
||||
identity_state["claims"]["email"] || identity_state["userinfo"]["email"]
|
||||
identity_state
|
||||
)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn identity ->
|
||||
@@ -146,6 +147,23 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_by_provider_claims(
|
||||
queryable,
|
||||
provider,
|
||||
provider_identifier,
|
||||
identity_state
|
||||
) do
|
||||
if provider.provisioner == :manual do
|
||||
Identity.Query.by_provider_claims(
|
||||
queryable,
|
||||
provider_identifier,
|
||||
identity_state["claims"]["email"] || identity_state["userinfo"]["email"]
|
||||
)
|
||||
else
|
||||
Identity.Query.by_provider_identifier(queryable, provider_identifier)
|
||||
end
|
||||
end
|
||||
|
||||
def verify_and_upsert_identity(
|
||||
%Actors.Actor{} = actor,
|
||||
%Provider{} = provider,
|
||||
|
||||
@@ -99,6 +99,62 @@ defmodule Domain.Auth.Identity.Query do
|
||||
end
|
||||
end
|
||||
|
||||
def by_membership_rules(queryable \\ not_deleted(), rules) do
|
||||
dynamic =
|
||||
Enum.reduce(rules, false, fn
|
||||
rule, false ->
|
||||
membership_rule_dynamic(rule)
|
||||
|
||||
rule, dynamic ->
|
||||
dynamic([identities: identities], ^dynamic or ^membership_rule_dynamic(rule))
|
||||
end)
|
||||
|
||||
where(queryable, ^dynamic)
|
||||
end
|
||||
|
||||
defp membership_rule_dynamic(%{path: path, operator: :is_in, values: values}) do
|
||||
dynamic(
|
||||
[identities: identities],
|
||||
fragment("? \\?| ?", json_extract_path(identities.provider_state, ^path), ^values)
|
||||
)
|
||||
end
|
||||
|
||||
defp membership_rule_dynamic(%{path: path, operator: :is_not_in, values: values}) do
|
||||
dynamic(
|
||||
[identities: identities],
|
||||
fragment("NOT (? \\?| ?)", json_extract_path(identities.provider_state, ^path), ^values)
|
||||
)
|
||||
end
|
||||
|
||||
defp membership_rule_dynamic(%{path: path, operator: :contains, values: [value]}) do
|
||||
dynamic(
|
||||
[identities: identities],
|
||||
fragment(
|
||||
"?->>0 LIKE '%' || ? || '%'",
|
||||
json_extract_path(identities.provider_state, ^path),
|
||||
^value
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp membership_rule_dynamic(%{path: path, operator: :does_not_contain, values: [value]}) do
|
||||
dynamic(
|
||||
[identities: identities],
|
||||
fragment(
|
||||
"?->>0 NOT LIKE '%' || ? || '%'",
|
||||
json_extract_path(identities.provider_state, ^path),
|
||||
^value
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp membership_rule_dynamic(%{operator: true}) do
|
||||
dynamic(
|
||||
[identities: identities],
|
||||
true
|
||||
)
|
||||
end
|
||||
|
||||
def lock(queryable \\ not_deleted()) do
|
||||
lock(queryable, "FOR UPDATE")
|
||||
end
|
||||
@@ -107,6 +163,12 @@ defmodule Domain.Auth.Identity.Query do
|
||||
select(queryable, [identities: identities], identities.id)
|
||||
end
|
||||
|
||||
def returning_distinct_actor_ids(queryable \\ not_deleted()) do
|
||||
queryable
|
||||
|> select([identities: identities], identities.actor_id)
|
||||
|> distinct(true)
|
||||
end
|
||||
|
||||
def group_by_provider_id(queryable \\ not_deleted()) do
|
||||
queryable
|
||||
|> group_by([identities: identities], identities.provider_id)
|
||||
|
||||
@@ -52,6 +52,9 @@ defmodule Domain.Auth.Identity.Sync do
|
||||
{:ok, actor_ids_by_provider_identifier}
|
||||
end
|
||||
)
|
||||
|> Ecto.Multi.run(:recalculate_dynamic_groups, fn _repo, _effects_so_far ->
|
||||
Domain.Actors.update_dynamic_group_memberships(provider.account_id)
|
||||
end)
|
||||
end
|
||||
|
||||
defp fetch_and_lock_provider_identities_query(provider) do
|
||||
|
||||
@@ -8,6 +8,12 @@ defmodule Domain.Ops do
|
||||
Domain.Repo.transaction(fn ->
|
||||
{:ok, account} = Domain.Accounts.create_account(%{name: account_name, slug: account_slug})
|
||||
|
||||
{:ok, _everyone_group} =
|
||||
Domain.Actors.create_managed_group(account, %{
|
||||
name: "Everyone",
|
||||
membership_rules: [%{operator: true}]
|
||||
})
|
||||
|
||||
{:ok, magic_link_provider} =
|
||||
Domain.Auth.create_provider(account, %{
|
||||
name: "Email",
|
||||
|
||||
@@ -129,10 +129,24 @@ defmodule Domain.Resources do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
|
||||
ids = resources |> Enum.map(& &1.id) |> Enum.uniq()
|
||||
|
||||
Resource.Query.by_id({:in, ids})
|
||||
|> Authorizer.for_subject(Resource, subject)
|
||||
|> Resource.Query.preload_few_actor_groups_for_each_resource(limit)
|
||||
|> Repo.peek(resources)
|
||||
{:ok, peek} =
|
||||
Resource.Query.by_id({:in, ids})
|
||||
|> Authorizer.for_subject(Resource, subject)
|
||||
|> Resource.Query.preload_few_actor_groups_for_each_resource(limit)
|
||||
|> Repo.peek(resources)
|
||||
|
||||
group_by_ids =
|
||||
Enum.flat_map(peek, fn {_id, %{items: items}} -> items end)
|
||||
|> Repo.preload(:provider)
|
||||
|> Enum.map(&{&1.id, &1})
|
||||
|> Enum.into(%{})
|
||||
|
||||
peek =
|
||||
for {id, %{items: items} = map} <- peek, into: %{} do
|
||||
{id, %{map | items: Enum.map(items, &Map.fetch!(group_by_ids, &1.id))}}
|
||||
end
|
||||
|
||||
{:ok, peek}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,10 +4,6 @@ defmodule Domain.Validator do
|
||||
"""
|
||||
import Ecto.Changeset
|
||||
|
||||
def changed?(changeset, field) do
|
||||
Map.has_key?(changeset.changes, field)
|
||||
end
|
||||
|
||||
def empty?(changeset, field) do
|
||||
case fetch_field(changeset, field) do
|
||||
:error -> true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
defmodule Domain.Repo.Migrations.AddActorGroupsTypeAndMembershipRules do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:actor_groups) do
|
||||
add(:type, :string)
|
||||
add(:membership_rules, {:array, :map}, default: [])
|
||||
end
|
||||
|
||||
execute("UPDATE actor_groups SET type = 'static'")
|
||||
execute("ALTER TABLE actor_groups ALTER COLUMN type SET NOT NULL")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
defmodule Domain.Repo.Migrations.SetOidcProvidersProvisionerToManual do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute("UPDATE auth_providers SET provisioner = 'manual' WHERE adapter = 'openid_connect'")
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,18 @@ end
|
||||
|
||||
IO.puts("")
|
||||
|
||||
{:ok, everyone_group} =
|
||||
Domain.Actors.create_managed_group(account, %{
|
||||
name: "Everyone",
|
||||
membership_rules: [%{operator: true}]
|
||||
})
|
||||
|
||||
{:ok, _everyone_group} =
|
||||
Domain.Actors.create_managed_group(other_account, %{
|
||||
name: "Everyone",
|
||||
membership_rules: [%{operator: true}]
|
||||
})
|
||||
|
||||
{:ok, email_provider} =
|
||||
Auth.create_provider(account, %{
|
||||
name: "Email",
|
||||
@@ -136,6 +148,21 @@ _unprivileged_actor_userpass_identity =
|
||||
}
|
||||
})
|
||||
|
||||
{:ok, admin_actor_oidc_identity} =
|
||||
Auth.create_identity(admin_actor, oidc_provider, %{
|
||||
provider_identifier: admin_actor_email,
|
||||
provider_identifier_confirmation: admin_actor_email
|
||||
})
|
||||
|
||||
admin_actor_oidc_identity
|
||||
|> Ecto.Changeset.change(
|
||||
created_by: :provider,
|
||||
provider_id: oidc_provider.id,
|
||||
provider_identifier: admin_actor_email,
|
||||
provider_state: %{"claims" => %{"email" => admin_actor_email, "group" => "users"}}
|
||||
)
|
||||
|> Repo.update!()
|
||||
|
||||
# Other Account Users
|
||||
other_unprivileged_actor_email = "other-unprivileged-1@localhost"
|
||||
other_admin_actor_email = "other@localhost"
|
||||
@@ -274,16 +301,11 @@ IO.puts("")
|
||||
|
||||
IO.puts("Created Actor Groups: ")
|
||||
|
||||
{:ok, eng_group} = Actors.create_group(%{name: "Engineering"}, admin_subject)
|
||||
{:ok, finance_group} = Actors.create_group(%{name: "Finance"}, admin_subject)
|
||||
{:ok, eng_group} = Actors.create_group(%{name: "Engineering", type: :static}, admin_subject)
|
||||
{:ok, finance_group} = Actors.create_group(%{name: "Finance", type: :static}, admin_subject)
|
||||
{:ok, synced_group} = Actors.create_group(%{name: "Synced Group", type: :static}, admin_subject)
|
||||
|
||||
{:ok, all_group} =
|
||||
Actors.create_group(
|
||||
%{name: "All Employees", provider_id: oidc_provider.id, provider_identifier: "foo"},
|
||||
admin_subject
|
||||
)
|
||||
|
||||
for group <- [eng_group, finance_group, all_group] do
|
||||
for group <- [eng_group, finance_group, synced_group] do
|
||||
IO.puts(" Name: #{group.name} ID: #{group.id}")
|
||||
end
|
||||
|
||||
@@ -301,7 +323,7 @@ finance_group
|
||||
admin_subject
|
||||
)
|
||||
|
||||
all_group
|
||||
synced_group
|
||||
|> Repo.preload(:memberships)
|
||||
|> Actors.update_group(
|
||||
%{
|
||||
@@ -313,6 +335,18 @@ all_group
|
||||
admin_subject
|
||||
)
|
||||
|
||||
synced_group
|
||||
|> Ecto.Changeset.change(
|
||||
created_by: :provider,
|
||||
provider_id: oidc_provider.id,
|
||||
provider_identifier: "dummy_oidc_group_id"
|
||||
)
|
||||
|> Repo.update!()
|
||||
|
||||
oidc_provider
|
||||
|> Ecto.Changeset.change(last_synced_at: DateTime.utc_now())
|
||||
|> Repo.update!()
|
||||
|
||||
IO.puts("")
|
||||
|
||||
{:ok, global_relay_group} =
|
||||
@@ -659,7 +693,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To Google",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: everyone_group.id,
|
||||
resource_id: dns_google_resource.id
|
||||
},
|
||||
admin_subject
|
||||
@@ -669,7 +703,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To firez.one",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: synced_group.id,
|
||||
resource_id: firez_one.id
|
||||
},
|
||||
admin_subject
|
||||
@@ -679,7 +713,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To firez.one",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: everyone_group.id,
|
||||
resource_id: example_dns.id
|
||||
},
|
||||
admin_subject
|
||||
@@ -689,7 +723,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To firezone.dev",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: everyone_group.id,
|
||||
resource_id: firezone_dev.id
|
||||
},
|
||||
admin_subject
|
||||
@@ -699,7 +733,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To ip6only.me",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: synced_group.id,
|
||||
resource_id: ip6only.id
|
||||
},
|
||||
admin_subject
|
||||
@@ -719,7 +753,7 @@ IO.puts("")
|
||||
Policies.create_policy(
|
||||
%{
|
||||
name: "All Access To Network",
|
||||
actor_group_id: all_group.id,
|
||||
actor_group_id: synced_group.id,
|
||||
resource_id: cidr_resource.id
|
||||
},
|
||||
admin_subject
|
||||
|
||||
@@ -298,6 +298,20 @@ defmodule Domain.ActorsTest do
|
||||
assert Enum.empty?(peek[actor2.id].items)
|
||||
end
|
||||
|
||||
test "preloads group providers", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
provider = Fixtures.Auth.create_userpass_provider(account: account)
|
||||
group = Fixtures.Actors.create_group(account: account, provider: provider)
|
||||
Fixtures.Actors.create_membership(account: account, actor: actor, group: group)
|
||||
|
||||
assert {:ok, peek} = peek_actor_groups([actor], 3, subject)
|
||||
assert [%Actors.Group{} = group] = peek[actor.id].items
|
||||
assert Ecto.assoc_loaded?(group.provider)
|
||||
end
|
||||
|
||||
test "returns count of actors per group and first LIMIT actors", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
@@ -966,6 +980,81 @@ defmodule Domain.ActorsTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "group_editable?/1" do
|
||||
test "returns false for synced groups" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
provider = Fixtures.Auth.create_userpass_provider(account: account)
|
||||
group = Fixtures.Actors.create_group(account: account, provider: provider)
|
||||
assert group_editable?(group) == false
|
||||
end
|
||||
|
||||
test "returns false for managed groups" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
assert group_editable?(group) == false
|
||||
end
|
||||
|
||||
test "returns false for manually created groups" do
|
||||
group = Fixtures.Actors.create_group()
|
||||
assert group_editable?(group) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_managed_group/2" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
%{
|
||||
account: account
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on empty attrs", %{account: account} do
|
||||
assert {:error, changeset} = create_managed_group(account, %{})
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["can't be blank"],
|
||||
membership_rules: ["can't be blank"]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account} do
|
||||
attrs = %{name: String.duplicate("A", 65)}
|
||||
assert {:error, changeset} = create_managed_group(account, attrs)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["should be at most 64 character(s)"],
|
||||
membership_rules: ["can't be blank"]
|
||||
}
|
||||
|
||||
Fixtures.Actors.create_managed_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo", type: :static, membership_rules: [%{operator: true}]}
|
||||
assert {:error, changeset} = create_managed_group(account, attrs)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "creates a group", %{account: account} do
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
identity = Fixtures.Auth.create_identity(account: account, actor: actor)
|
||||
:ok = subscribe_to_membership_updates_for_actor(actor)
|
||||
|
||||
attrs = Fixtures.Actors.group_attrs(membership_rules: [%{operator: true}])
|
||||
|
||||
assert {:ok, group} = create_managed_group(account, attrs)
|
||||
assert group.id
|
||||
assert group.name == attrs.name
|
||||
|
||||
group = Repo.preload(group, :memberships)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.group_id == group.id
|
||||
assert membership.actor_id == identity.actor_id
|
||||
|
||||
assert_receive {:create_membership, actor_id, group_id}
|
||||
assert actor_id == actor.id
|
||||
assert group_id == group.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_group/2" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
@@ -983,16 +1072,24 @@ defmodule Domain.ActorsTest do
|
||||
|
||||
test "returns error on empty attrs", %{subject: subject} do
|
||||
assert {:error, changeset} = create_group(%{}, subject)
|
||||
assert errors_on(changeset) == %{name: ["can't be blank"]}
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["can't be blank"],
|
||||
type: ["can't be blank"]
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
attrs = %{name: String.duplicate("A", 65)}
|
||||
attrs = %{name: String.duplicate("A", 65), type: :foo}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert errors_on(changeset) == %{name: ["should be at most 64 character(s)"]}
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["should be at most 64 character(s)"],
|
||||
type: ["is invalid"]
|
||||
}
|
||||
|
||||
Fixtures.Actors.create_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo", tokens: [%{}]}
|
||||
attrs = %{name: "foo", type: :static}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
@@ -1021,6 +1118,7 @@ defmodule Domain.ActorsTest do
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == attrs.name
|
||||
assert group.type == attrs.type
|
||||
|
||||
group = Repo.preload(group, :memberships)
|
||||
assert [%Actors.Membership{} = membership] = group.memberships
|
||||
@@ -1052,18 +1150,18 @@ defmodule Domain.ActorsTest do
|
||||
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
group = Fixtures.Actors.create_group(account: account) |> Repo.preload(:memberships)
|
||||
|
||||
group_attrs =
|
||||
attrs =
|
||||
Fixtures.Actors.group_attrs(
|
||||
memberships: [
|
||||
%{actor_id: actor.id}
|
||||
]
|
||||
)
|
||||
|
||||
assert changeset = change_group(group, group_attrs)
|
||||
assert changeset = change_group(group, attrs)
|
||||
assert changeset.valid?
|
||||
|
||||
assert %{name: name, memberships: [membership]} = changeset.changes
|
||||
assert name == group_attrs.name
|
||||
assert name == attrs.name
|
||||
assert membership.changes.account_id == account.id
|
||||
assert membership.changes.actor_id == actor.id
|
||||
end
|
||||
@@ -1077,6 +1175,16 @@ defmodule Domain.ActorsTest do
|
||||
change_group(group, %{})
|
||||
end
|
||||
end
|
||||
|
||||
test "raises if group is managed" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
provider = Fixtures.Auth.create_userpass_provider(account: account)
|
||||
group = Fixtures.Actors.create_managed_group(account: account, provider: provider)
|
||||
|
||||
assert_raise ArgumentError, "can't change managed groups", fn ->
|
||||
change_group(group, %{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_group/3" do
|
||||
@@ -1127,6 +1235,52 @@ defmodule Domain.ActorsTest do
|
||||
assert group.name == attrs.name
|
||||
end
|
||||
|
||||
test "updates dynamic group membership rules", %{account: account, subject: subject} do
|
||||
group =
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
account: account,
|
||||
membership_rules: [%{operator: true}]
|
||||
)
|
||||
|
||||
attrs =
|
||||
Fixtures.Actors.group_attrs(
|
||||
membership_rules: [
|
||||
%{path: ["claims", "group"], operator: "contains", values: ["admin"]}
|
||||
]
|
||||
)
|
||||
|
||||
assert {:ok, group} = update_group(group, attrs, subject)
|
||||
|
||||
assert group.membership_rules == [
|
||||
%Domain.Actors.MembershipRule{
|
||||
path: ["claims", "group"],
|
||||
operator: :contains,
|
||||
values: ["admin"]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group =
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
account: account,
|
||||
membership_rules: [
|
||||
%{path: ["claims", "email"], operator: "is_in", values: ["xxx@fz.one"]}
|
||||
]
|
||||
)
|
||||
|
||||
assert Repo.aggregate(Actors.Membership.Query.by_group_id(group.id), :count) == 0
|
||||
|
||||
attrs = %{membership_rules: [%{operator: "true"}]}
|
||||
assert {:ok, %{memberships: memberships}} = update_group(group, attrs, subject)
|
||||
assert length(memberships) == 2
|
||||
end
|
||||
|
||||
test "updates group memberships and triggers policy access events", %{
|
||||
account: account,
|
||||
actor: actor1,
|
||||
@@ -1223,7 +1377,7 @@ defmodule Domain.ActorsTest do
|
||||
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
|
||||
end
|
||||
|
||||
test "raises if group is synced", %{
|
||||
test "returns error if group is synced", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
@@ -1232,6 +1386,143 @@ defmodule Domain.ActorsTest do
|
||||
|
||||
assert update_group(group, %{}, subject) == {:error, :synced_group}
|
||||
end
|
||||
|
||||
test "returns error if group is managed", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
provider = Fixtures.Auth.create_userpass_provider(account: account)
|
||||
group = Fixtures.Actors.create_managed_group(account: account, provider: provider)
|
||||
|
||||
assert update_group(group, %{}, subject) == {:error, :managed_group}
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_dynamic_group_memberships/1" do
|
||||
test "updates memberships" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [%{operator: true}],
|
||||
account: account
|
||||
)
|
||||
|
||||
identity = Fixtures.Auth.create_identity(account: account)
|
||||
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
|
||||
assert memberships = Repo.all(Actors.Membership)
|
||||
assert length(memberships) == 2
|
||||
assert Enum.any?(memberships, fn membership -> membership.actor_id == identity.actor_id end)
|
||||
end
|
||||
|
||||
test "broadcasts events" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
group =
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [%{operator: true}],
|
||||
account: account
|
||||
)
|
||||
|
||||
actor = Fixtures.Actors.create_actor(account: account)
|
||||
:ok = subscribe_to_membership_updates_for_actor(actor)
|
||||
|
||||
# this function will call update_dynamic_group_memberships by itself
|
||||
Fixtures.Auth.create_identity(account: account, actor: actor)
|
||||
|
||||
assert_receive {:create_membership, actor_id, group_id}
|
||||
assert actor_id == actor.id
|
||||
assert group_id == group.id
|
||||
|
||||
# doesn't broadcast events when memberships are not changed
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
refute_receive {:create_membership, _actor_id, _group_id}
|
||||
end
|
||||
|
||||
test "allows to use is_in operator" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [%{path: ["claims", "group"], operator: :is_in, values: ["admin"]}],
|
||||
account: account
|
||||
)
|
||||
|
||||
identity =
|
||||
Fixtures.Auth.create_identity(account: account)
|
||||
|> Ecto.Changeset.change(provider_state: %{"claims" => %{"group" => "admin"}})
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
|
||||
assert membership = Repo.one(Actors.Membership)
|
||||
assert membership.actor_id == identity.actor_id
|
||||
end
|
||||
|
||||
test "allows to use is_not_in operator" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [%{path: ["claims", "group"], operator: :is_not_in, values: ["user"]}],
|
||||
account: account
|
||||
)
|
||||
|
||||
identity =
|
||||
Fixtures.Auth.create_identity(account: account)
|
||||
|> Ecto.Changeset.change(provider_state: %{"claims" => %{"group" => "admin"}})
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
|
||||
assert membership = Repo.one(Actors.Membership)
|
||||
assert membership.actor_id == identity.actor_id
|
||||
end
|
||||
|
||||
test "allows to use contains operator" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [%{path: ["claims", "group"], operator: :contains, values: ["ad"]}],
|
||||
account: account
|
||||
)
|
||||
|
||||
identity =
|
||||
Fixtures.Auth.create_identity(account: account)
|
||||
|> Ecto.Changeset.change(provider_state: %{"claims" => %{"group" => "admin"}})
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
|
||||
assert membership = Repo.one(Actors.Membership)
|
||||
assert membership.actor_id == identity.actor_id
|
||||
end
|
||||
|
||||
test "allows to use does_not_contain operator" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
|
||||
Fixtures.Actors.create_group(
|
||||
type: :dynamic,
|
||||
membership_rules: [
|
||||
%{path: ["claims", "group"], operator: :does_not_contain, values: ["usr"]}
|
||||
],
|
||||
account: account
|
||||
)
|
||||
|
||||
identity =
|
||||
Fixtures.Auth.create_identity(account: account)
|
||||
|> Ecto.Changeset.change(provider_state: %{"claims" => %{"group" => "admin"}})
|
||||
|> Repo.update!()
|
||||
|
||||
assert {:ok, [_group]} = update_dynamic_group_memberships(account.id)
|
||||
|
||||
assert membership = Repo.one(Actors.Membership)
|
||||
assert membership.actor_id == identity.actor_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_group/2" do
|
||||
@@ -1732,20 +2023,35 @@ defmodule Domain.ActorsTest do
|
||||
describe "create_actor/5" do
|
||||
setup do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
subject = Fixtures.Auth.create_subject(account: account, actor: actor)
|
||||
|
||||
%{
|
||||
account: account
|
||||
account: account,
|
||||
actor: actor,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
test "returns error when subject can not create actors", %{
|
||||
account: account
|
||||
test "creates an actor", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
attrs = Fixtures.Actors.actor_attrs()
|
||||
|
||||
subject =
|
||||
Fixtures.Auth.create_subject(account: account, actor: actor)
|
||||
|> Fixtures.Auth.remove_permissions()
|
||||
assert {:ok, actor} = create_actor(account, attrs, subject)
|
||||
|
||||
assert actor.type == attrs.type
|
||||
assert actor.type == attrs.type
|
||||
assert is_nil(actor.disabled_at)
|
||||
assert is_nil(actor.deleted_at)
|
||||
end
|
||||
|
||||
test "returns error when subject can not create actors", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
subject = Fixtures.Auth.remove_permissions(subject)
|
||||
|
||||
attrs = %{}
|
||||
|
||||
@@ -1765,12 +2071,9 @@ defmodule Domain.ActorsTest do
|
||||
end
|
||||
|
||||
test "returns error when subject is trying to create an actor with a privilege escalation", %{
|
||||
account: account
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
|
||||
subject = Fixtures.Auth.create_subject(account: account, actor: actor)
|
||||
|
||||
required_permissions = [Actors.Authorizer.manage_actors_permission()]
|
||||
|
||||
subject =
|
||||
@@ -2205,6 +2508,18 @@ defmodule Domain.ActorsTest do
|
||||
assert is_nil(other_actor.deleted_at)
|
||||
end
|
||||
|
||||
test "updates dynamic groups memberships", %{account: account, actor: actor, subject: subject} do
|
||||
Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, actor} = delete_actor(actor, subject)
|
||||
assert actor.deleted_at
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert group.memberships == []
|
||||
end
|
||||
|
||||
test "deletes token and broadcasts message to disconnect the actor sessions", %{
|
||||
account: account,
|
||||
actor: actor,
|
||||
|
||||
@@ -592,7 +592,7 @@ defmodule Domain.AuthTest do
|
||||
Fixtures.Auth.provider_attrs(
|
||||
adapter: :openid_connect,
|
||||
adapter_config: provider.adapter_config,
|
||||
provisioner: :just_in_time
|
||||
provisioner: :manual
|
||||
)
|
||||
|
||||
assert {:error, changeset} = create_provider(account, attrs)
|
||||
@@ -767,7 +767,7 @@ defmodule Domain.AuthTest do
|
||||
} do
|
||||
attrs =
|
||||
Fixtures.Auth.provider_attrs(
|
||||
provisioner: :just_in_time,
|
||||
provisioner: :manual,
|
||||
adapter: :foobar,
|
||||
adapter_config: %{
|
||||
client_id: "foo"
|
||||
@@ -1750,7 +1750,8 @@ defmodule Domain.AuthTest do
|
||||
delete_identities: [],
|
||||
insert_identities: [],
|
||||
update_identities_and_actors: [],
|
||||
actor_ids_by_provider_identifier: %{}
|
||||
actor_ids_by_provider_identifier: %{},
|
||||
recalculate_dynamic_groups: []
|
||||
}}
|
||||
end
|
||||
|
||||
@@ -1861,6 +1862,27 @@ defmodule Domain.AuthTest do
|
||||
assert updated_identity.provider_state == %{}
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: actor
|
||||
} do
|
||||
provider_identifier = Fixtures.Auth.random_provider_identifier(provider)
|
||||
|
||||
attrs = %{
|
||||
provider_identifier: provider_identifier,
|
||||
provider_identifier_confirmation: provider_identifier
|
||||
}
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, _identity} = upsert_identity(actor, provider, attrs)
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.actor_id == actor.id
|
||||
end
|
||||
|
||||
test "returns error when identifier is invalid", %{
|
||||
provider: provider,
|
||||
actor: actor
|
||||
@@ -1992,6 +2014,29 @@ defmodule Domain.AuthTest do
|
||||
assert is_nil(identity.deleted_at)
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: actor,
|
||||
subject: subject
|
||||
} do
|
||||
provider_identifier = Fixtures.Auth.random_provider_identifier(provider)
|
||||
|
||||
attrs = %{
|
||||
provider_identifier: provider_identifier,
|
||||
provider_identifier_confirmation: provider_identifier
|
||||
}
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, _identity} = create_identity(actor, provider, attrs, subject)
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.actor_id == actor.id
|
||||
assert membership.group_id == group.id
|
||||
end
|
||||
|
||||
test "returns error when identity already exists", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
@@ -2089,6 +2134,34 @@ defmodule Domain.AuthTest do
|
||||
assert is_nil(identity.deleted_at)
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
provider = Fixtures.Auth.create_userpass_provider(account: account)
|
||||
provider_identifier = Fixtures.Auth.random_provider_identifier(provider)
|
||||
|
||||
actor =
|
||||
Fixtures.Actors.create_actor(
|
||||
type: :account_admin_user,
|
||||
account: account,
|
||||
provider: provider
|
||||
)
|
||||
|
||||
password = "Firezone1234"
|
||||
|
||||
attrs = %{
|
||||
provider_identifier: provider_identifier,
|
||||
provider_virtual_state: %{"password" => password, "password_confirmation" => password}
|
||||
}
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, _identity} = create_identity(actor, provider, attrs)
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.actor_id == actor.id
|
||||
end
|
||||
|
||||
test "returns error when identifier is invalid" do
|
||||
account = Fixtures.Accounts.create_account()
|
||||
Domain.Config.put_env_override(:outbound_email_adapter_configured?, true)
|
||||
@@ -2228,6 +2301,29 @@ defmodule Domain.AuthTest do
|
||||
assert Repo.get(Auth.Identity, identity.id).deleted_at
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
identity: identity,
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
provider_identifier = Fixtures.Auth.random_provider_identifier(provider)
|
||||
|
||||
attrs = %{
|
||||
provider_identifier: provider_identifier,
|
||||
provider_identifier_confirmation: provider_identifier
|
||||
}
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, identity} = replace_identity(identity, attrs, subject)
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.actor_id == identity.actor_id
|
||||
assert membership.group_id == group.id
|
||||
end
|
||||
|
||||
test "deletes tokens of replaced identity and broadcasts disconnect message", %{
|
||||
account: account,
|
||||
identity: identity,
|
||||
@@ -2329,6 +2425,24 @@ defmodule Domain.AuthTest do
|
||||
assert Repo.get(Auth.Identity, identity.id).deleted_at
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
actor: actor,
|
||||
subject: subject
|
||||
} do
|
||||
identity = Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor)
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert {:ok, _identity} = delete_identity(identity, subject)
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert [membership] = group.memberships
|
||||
assert membership.actor_id == actor.id
|
||||
assert membership.group_id == group.id
|
||||
end
|
||||
|
||||
test "deletes identity that belongs to another actor with manage permission", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
@@ -2522,6 +2636,23 @@ defmodule Domain.AuthTest do
|
||||
assert DateTime.diff(expires_at, DateTime.utc_now()) < 1
|
||||
end
|
||||
|
||||
test "updates dynamic group memberships", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
subject: subject
|
||||
} do
|
||||
actor = Fixtures.Actors.create_actor(account: account, provider: provider)
|
||||
Fixtures.Auth.create_identity(account: account, provider: provider, actor: actor)
|
||||
|
||||
group = Fixtures.Actors.create_managed_group(account: account)
|
||||
|
||||
assert delete_identities_for(actor, subject) == :ok
|
||||
|
||||
group = Repo.preload(group, :memberships, force: true)
|
||||
assert length(group.memberships) == 1
|
||||
refute Enum.any?(group.memberships, &(&1.actor_id == actor.id))
|
||||
end
|
||||
|
||||
test "does not remove identities that belong to another actor", %{
|
||||
account: account,
|
||||
provider: provider,
|
||||
|
||||
@@ -701,6 +701,18 @@ defmodule Domain.ResourcesTest do
|
||||
assert Enum.empty?(peek[resource2.id].items)
|
||||
end
|
||||
|
||||
test "preloads group providers", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
resource = Fixtures.Resources.create_resource(account: account)
|
||||
Fixtures.Policies.create_policy(account: account, resource: resource)
|
||||
|
||||
assert {:ok, peek} = peek_resource_actor_groups([resource], 3, subject)
|
||||
assert [%Actors.Group{} = group] = peek[resource.id].items
|
||||
assert Ecto.assoc_loaded?(group.provider)
|
||||
end
|
||||
|
||||
test "returns count of policies per resource and first LIMIT actors", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
|
||||
@@ -4,10 +4,27 @@ defmodule Domain.Fixtures.Actors do
|
||||
|
||||
def group_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name: "group-#{unique_integer()}"
|
||||
name: "group-#{unique_integer()}",
|
||||
type: :static
|
||||
})
|
||||
end
|
||||
|
||||
def create_managed_group(attrs \\ %{}) do
|
||||
attrs =
|
||||
attrs
|
||||
|> group_attrs()
|
||||
|> Map.put(:type, :managed)
|
||||
|> Map.put_new(:membership_rules, [%{operator: true}])
|
||||
|
||||
{account, attrs} =
|
||||
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
|
||||
Fixtures.Accounts.create_account(assoc_attrs)
|
||||
end)
|
||||
|
||||
{:ok, group} = Actors.create_managed_group(account, attrs)
|
||||
group
|
||||
end
|
||||
|
||||
def create_group(attrs \\ %{}) do
|
||||
attrs = group_attrs(attrs)
|
||||
|
||||
@@ -38,7 +55,10 @@ defmodule Domain.Fixtures.Actors do
|
||||
|> Actors.create_group(subject)
|
||||
|
||||
if provider do
|
||||
update!(group, provider_id: provider.id, provider_identifier: provider_identifier)
|
||||
update!(group,
|
||||
provider_id: provider.id,
|
||||
provider_identifier: provider_identifier
|
||||
)
|
||||
else
|
||||
group
|
||||
end
|
||||
|
||||
@@ -119,7 +119,7 @@ defmodule Domain.Fixtures.Auth do
|
||||
attrs =
|
||||
%{
|
||||
adapter: :openid_connect,
|
||||
provisioner: :just_in_time
|
||||
provisioner: :manual
|
||||
}
|
||||
|> Map.merge(Enum.into(attrs, %{}))
|
||||
|> provider_attrs()
|
||||
|
||||
@@ -862,7 +862,7 @@ defmodule Web.CoreComponents do
|
||||
|
||||
def created_by(%{schema: %{created_by: :system}} = assigns) do
|
||||
~H"""
|
||||
<.relative_datetime datetime={@schema.inserted_at} />
|
||||
<.relative_datetime datetime={@schema.inserted_at} /> by system
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -880,13 +880,13 @@ defmodule Web.CoreComponents do
|
||||
|
||||
def created_by(%{schema: %{created_by: :provider}} = assigns) do
|
||||
~H"""
|
||||
synced <.relative_datetime datetime={@schema.inserted_at} /> from
|
||||
<.relative_datetime datetime={@schema.inserted_at} /> by
|
||||
<.link
|
||||
class="text-accent-500 hover:underline"
|
||||
navigate={Web.Settings.IdentityProviders.Components.view_provider(@account, @schema.provider)}
|
||||
>
|
||||
<%= @schema.provider.name %>
|
||||
</.link>
|
||||
</.link> sync
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -962,7 +962,7 @@ defmodule Web.CoreComponents do
|
||||
"rounded-l",
|
||||
"py-0.5 pl-2.5 pr-1.5",
|
||||
"text-neutral-800",
|
||||
"bg-neutral-100",
|
||||
"bg-neutral-200",
|
||||
"whitespace-nowrap"
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ defmodule Web.FormComponents do
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
range radio search select tel text textarea taglist time url week)
|
||||
range radio search group_select select tel text textarea taglist time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
@@ -113,6 +113,30 @@ defmodule Web.FormComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "group_select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select id={@id} name={@name} class={~w[
|
||||
bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded
|
||||
block w-full p-2.5]} multiple={@multiple} {@rest}>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
|
||||
<%= for {label, options} <- @options do %>
|
||||
<%= if label == nil do %>
|
||||
<%= Phoenix.HTML.Form.options_for_select(options, @value) %>
|
||||
<% else %>
|
||||
<optgroup label={label}>
|
||||
<%= Phoenix.HTML.Form.options_for_select(options, @value) %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
|
||||
@@ -136,7 +136,7 @@ defmodule Web.TableComponents do
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<div :if={Enum.empty?(@rows)}>
|
||||
<div :if={Enum.empty?(@rows)} id={"#{@id}-empty"}>
|
||||
<%= render_slot(@empty) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,12 +78,12 @@ defmodule Web.Actors.Components do
|
||||
</div>
|
||||
<div :if={@groups != []}>
|
||||
<.input
|
||||
type="select"
|
||||
type="group_select"
|
||||
multiple={true}
|
||||
label="Groups"
|
||||
field={@form[:memberships]}
|
||||
value_id={fn membership -> membership.group_id end}
|
||||
options={Enum.map(@groups, fn group -> {group.name, group.id} end)}
|
||||
options={Web.Groups.Components.select_options(@groups)}
|
||||
placeholder="Groups"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-neutral-500">
|
||||
|
||||
@@ -7,10 +7,10 @@ defmodule Web.Actors.Edit do
|
||||
with {:ok, actor} <-
|
||||
Actors.fetch_actor_by_id(id, socket.assigns.subject, preload: [:memberships]),
|
||||
nil <- actor.deleted_at,
|
||||
{:ok, groups} <- Actors.list_groups(socket.assigns.subject) do
|
||||
changeset = Actors.change_actor(actor)
|
||||
{:ok, groups} <- Actors.list_groups(socket.assigns.subject, preload: [:provider]) do
|
||||
groups = Enum.filter(groups, &Actors.group_editable?/1)
|
||||
|
||||
groups = Enum.reject(groups, &Actors.group_synced?/1)
|
||||
changeset = Actors.change_actor(actor)
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
defmodule Web.Actors.Index do
|
||||
use Web, :live_view
|
||||
import Web.Actors.Components
|
||||
alias Domain.Auth
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
with {:ok, actors} <-
|
||||
Actors.list_actors(socket.assigns.subject, preload: [identities: :provider]),
|
||||
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject),
|
||||
{:ok, providers} <- Auth.list_providers(socket.assigns.subject) do
|
||||
{:ok, actor_groups} <- Actors.peek_actor_groups(actors, 3, socket.assigns.subject) do
|
||||
socket =
|
||||
assign(socket,
|
||||
actors: actors,
|
||||
actor_groups: actor_groups,
|
||||
providers_by_id: Map.new(providers, &{&1.id, &1}),
|
||||
page_title: "Actors"
|
||||
)
|
||||
|
||||
@@ -63,10 +60,7 @@ defmodule Web.Actors.Index do
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<.group
|
||||
account={@account}
|
||||
group={%{group | provider: Map.get(@providers_by_id, group.provider_id)}}
|
||||
/>
|
||||
<.group account={@account} group={group} />
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
|
||||
@@ -4,10 +4,10 @@ defmodule Web.Actors.ServiceAccounts.New do
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
with {:ok, groups} <- Actors.list_groups(socket.assigns.subject) do
|
||||
changeset = Actors.new_actor(%{type: :service_account})
|
||||
with {:ok, groups} <- Actors.list_editable_groups(socket.assigns.subject, preload: :provider) do
|
||||
groups = Enum.filter(groups, &Actors.group_editable?/1)
|
||||
|
||||
groups = Enum.reject(groups, &Actors.group_synced?/1)
|
||||
changeset = Actors.new_actor(%{type: :service_account})
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
|
||||
@@ -170,22 +170,24 @@ defmodule Web.Actors.Show do
|
||||
<div class="flex justify-center text-center text-neutral-500 p-4">
|
||||
<div class="w-auto pb-4">
|
||||
No authentication identities to display.
|
||||
<.link
|
||||
:if={is_nil(@actor.deleted_at) and @actor.type == :service_account}
|
||||
class={[link_style()]}
|
||||
navigate={~p"/#{@account}/actors/service_accounts/#{@actor}/new_identity"}
|
||||
>
|
||||
Create a token
|
||||
</.link>
|
||||
to authenticate this service account.
|
||||
<.link
|
||||
:if={is_nil(@actor.deleted_at) and @actor.type != :service_account}
|
||||
class={[link_style()]}
|
||||
navigate={~p"/#{@account}/actors/users/#{@actor}/new_identity"}
|
||||
>
|
||||
Create an identity
|
||||
</.link>
|
||||
to authenticate this user.
|
||||
<span :if={is_nil(@actor.deleted_at) and @actor.type == :service_account}>
|
||||
<.link
|
||||
class={[link_style()]}
|
||||
navigate={~p"/#{@account}/actors/service_accounts/#{@actor}/new_identity"}
|
||||
>
|
||||
Create a token
|
||||
</.link>
|
||||
to authenticate this service account.
|
||||
</span>
|
||||
<span :if={is_nil(@actor.deleted_at) and @actor.type != :service_account}>
|
||||
<.link
|
||||
class={[link_style()]}
|
||||
navigate={~p"/#{@account}/actors/users/#{@actor}/new_identity"}
|
||||
>
|
||||
Create an identity
|
||||
</.link>
|
||||
to authenticate this user.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</:empty>
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Web.Actors.Users.New do
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
with {:ok, groups} <- Actors.list_groups(socket.assigns.subject) do
|
||||
with {:ok, groups} <- Actors.list_editable_groups(socket.assigns.subject, preload: :provider) do
|
||||
changeset = Actors.new_actor()
|
||||
|
||||
socket =
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
defmodule Web.Groups.Components do
|
||||
use Web, :component_library
|
||||
alias Domain.Actors
|
||||
|
||||
attr :account, :any, required: true
|
||||
attr :group, :any, required: true
|
||||
def select_options(groups) do
|
||||
groups
|
||||
|> Enum.group_by(&options_index_and_label/1)
|
||||
|> Enum.sort_by(fn {{priority, label}, _groups} ->
|
||||
{priority, label}
|
||||
end)
|
||||
|> Enum.map(fn {{_priority, label}, groups} ->
|
||||
options = groups |> Enum.sort_by(& &1.name) |> Enum.map(&group_option/1)
|
||||
{label, options}
|
||||
end)
|
||||
end
|
||||
|
||||
def source(assigns) do
|
||||
~H"""
|
||||
<span :if={not is_nil(@group.provider_id)}>
|
||||
Synced from
|
||||
<.link
|
||||
class="text-accent-500 hover:underline"
|
||||
navigate={Web.Settings.IdentityProviders.Components.view_provider(@account, @group.provider)}
|
||||
>
|
||||
<%= @group.provider.name %>
|
||||
</.link>
|
||||
<.relative_datetime datetime={@group.provider.last_synced_at} />
|
||||
</span>
|
||||
<span :if={is_nil(@group.provider_id)}>
|
||||
<.created_by account={@account} schema={@group} />
|
||||
</span>
|
||||
"""
|
||||
defp options_index_and_label(group) do
|
||||
index =
|
||||
cond do
|
||||
Actors.group_synced?(group) -> 9
|
||||
Actors.group_managed?(group) -> 1
|
||||
true -> 2
|
||||
end
|
||||
|
||||
label =
|
||||
cond do
|
||||
Actors.group_synced?(group) -> group.provider.name
|
||||
Actors.group_managed?(group) -> nil
|
||||
true -> nil
|
||||
end
|
||||
|
||||
{index, label}
|
||||
end
|
||||
|
||||
defp group_option(group) do
|
||||
[key: group.name, value: group.id]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ defmodule Web.Groups.Edit do
|
||||
with {:ok, group} <-
|
||||
Actors.fetch_group_by_id(id, socket.assigns.subject, preload: [:memberships]),
|
||||
nil <- group.deleted_at,
|
||||
false <- Actors.group_synced?(group) do
|
||||
true <- Actors.group_editable?(group) do
|
||||
changeset = Actors.change_group(group)
|
||||
|
||||
socket =
|
||||
|
||||
@@ -7,7 +7,7 @@ defmodule Web.Groups.EditActors do
|
||||
with {:ok, group} <-
|
||||
Actors.fetch_group_by_id(id, socket.assigns.subject, preload: [:memberships]),
|
||||
nil <- group.deleted_at,
|
||||
false <- Actors.group_synced?(group),
|
||||
true <- Actors.group_editable?(group),
|
||||
{:ok, actors} <-
|
||||
Actors.list_actors(socket.assigns.subject, preload: [identities: :provider]) do
|
||||
current_member_ids = Enum.map(group.memberships, & &1.actor_id)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
defmodule Web.Groups.Index do
|
||||
use Web, :live_view
|
||||
import Web.Groups.Components
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
@@ -74,7 +73,7 @@ defmodule Web.Groups.Index do
|
||||
</.peek>
|
||||
</:col>
|
||||
<:col :let={group} label="SOURCE" sortable="false">
|
||||
<.source account={@account} group={group} />
|
||||
<.created_by account={@account} schema={group} />
|
||||
</:col>
|
||||
<:empty>
|
||||
<div class="flex justify-center text-center text-neutral-500 p-4">
|
||||
@@ -95,7 +94,6 @@ defmodule Web.Groups.Index do
|
||||
</div>
|
||||
</:empty>
|
||||
</.table>
|
||||
<!--<.paginator page={3} total_pages={100} collection_base_path={~p"/#{@account}/groups"} />-->
|
||||
</div>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
@@ -3,7 +3,7 @@ defmodule Web.Groups.New do
|
||||
alias Domain.Actors
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Actors.new_group()
|
||||
changeset = Actors.new_group(%{type: :static})
|
||||
|
||||
{:ok, assign(socket, form: to_form(changeset), page_title: "New Group"),
|
||||
temporary_assigns: [form: %Phoenix.HTML.Form{}]}
|
||||
@@ -22,6 +22,7 @@ defmodule Web.Groups.New do
|
||||
<:content>
|
||||
<div class="py-8 px-4 mx-auto max-w-2xl lg:py-16">
|
||||
<.form for={@form} phx-change={:change} phx-submit={:submit}>
|
||||
<.input type="hidden" field={@form[:type]} value="static" />
|
||||
<div class="grid gap-4 mb-4 sm:grid-cols-1 sm:gap-6 sm:mb-6">
|
||||
<div>
|
||||
<.input
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
defmodule Web.Groups.Show do
|
||||
use Web, :live_view
|
||||
import Web.Groups.Components
|
||||
import Web.Actors.Components
|
||||
alias Domain.Actors
|
||||
|
||||
@@ -36,23 +35,44 @@ defmodule Web.Groups.Show do
|
||||
</:title>
|
||||
<:action :if={is_nil(@group.deleted_at)}>
|
||||
<.edit_button
|
||||
:if={not Actors.group_synced?(@group)}
|
||||
:if={Actors.group_editable?(@group)}
|
||||
navigate={~p"/#{@account}/groups/#{@group}/edit"}
|
||||
>
|
||||
Edit Group
|
||||
</.edit_button>
|
||||
</:action>
|
||||
<:content>
|
||||
<.flash
|
||||
:if={
|
||||
Actors.group_managed?(@group) and
|
||||
not Enum.any?(@group.membership_rules, &(&1 == %Actors.MembershipRule{operator: true}))
|
||||
}
|
||||
kind={:info}
|
||||
>
|
||||
This group is managed by Firezone and cannot be edited.
|
||||
</.flash>
|
||||
<.flash
|
||||
:if={
|
||||
Actors.group_managed?(@group) and
|
||||
Enum.any?(@group.membership_rules, &(&1 == %Actors.MembershipRule{operator: true}))
|
||||
}
|
||||
kind={:info}
|
||||
>
|
||||
<p>This group is managed by Firezone and cannot be edited.</p>
|
||||
<p>It will contain all actors with at least one authentication identity.</p>
|
||||
</.flash>
|
||||
<.flash :if={Actors.group_synced?(@group)} kind={:info}>
|
||||
This group is synced from an external source and cannot be edited.
|
||||
</.flash>
|
||||
|
||||
<.vertical_table id="group">
|
||||
<.vertical_table_row>
|
||||
<:label>Name</:label>
|
||||
<:value><%= @group.name %></:value>
|
||||
</.vertical_table_row>
|
||||
<.vertical_table_row>
|
||||
<:label>Source</:label>
|
||||
<:value>
|
||||
<.source account={@account} group={@group} />
|
||||
</:value>
|
||||
<:label>Created</:label>
|
||||
<:value><.created_by account={@account} schema={@group} /></:value>
|
||||
</.vertical_table_row>
|
||||
</.vertical_table>
|
||||
</:content>
|
||||
@@ -62,7 +82,7 @@ defmodule Web.Groups.Show do
|
||||
<:title>Actors</:title>
|
||||
<:action :if={is_nil(@group.deleted_at)}>
|
||||
<.edit_button
|
||||
:if={not Actors.group_synced?(@group)}
|
||||
:if={not Actors.group_synced?(@group) and not Actors.group_managed?(@group)}
|
||||
navigate={~p"/#{@account}/groups/#{@group}/edit_actors"}
|
||||
>
|
||||
Edit Actors
|
||||
@@ -84,25 +104,22 @@ defmodule Web.Groups.Show do
|
||||
<div class="flex justify-center text-center text-neutral-500 p-4">
|
||||
<div :if={not Actors.group_synced?(@group)} class="w-auto">
|
||||
<div class="pb-4">
|
||||
No actors in group
|
||||
There are no actors in this group.
|
||||
</div>
|
||||
<.edit_button
|
||||
:if={is_nil(@group.deleted_at)}
|
||||
navigate={~p"/#{@account}/groups/#{@group}/edit"}
|
||||
:if={not Actors.group_synced?(@group) and not Actors.group_managed?(@group)}
|
||||
navigate={~p"/#{@account}/groups/#{@group}/edit_actors"}
|
||||
>
|
||||
Edit Group
|
||||
Edit Actors
|
||||
</.edit_button>
|
||||
</div>
|
||||
<div :if={Actors.group_synced?(@group)} class="w-auto">
|
||||
No actors in synced group
|
||||
</div>
|
||||
</div>
|
||||
</:empty>
|
||||
</.table>
|
||||
</:content>
|
||||
</.section>
|
||||
|
||||
<.danger_zone :if={is_nil(@group.deleted_at) and not Actors.group_synced?(@group)}>
|
||||
<.danger_zone :if={is_nil(@group.deleted_at) and Actors.group_editable?(@group)}>
|
||||
<:action>
|
||||
<.delete_button
|
||||
phx-click="delete"
|
||||
|
||||
@@ -14,7 +14,7 @@ defmodule Web.Policies.Index do
|
||||
defp load_policies_with_assocs(socket) do
|
||||
with {:ok, policies} <-
|
||||
Policies.list_policies(socket.assigns.subject,
|
||||
preload: [:actor_group, :resource]
|
||||
preload: [actor_group: [:provider], resource: []]
|
||||
) do
|
||||
{:ok, assign(socket, policies: policies)}
|
||||
end
|
||||
@@ -41,11 +41,7 @@ defmodule Web.Policies.Index do
|
||||
</.link>
|
||||
</:col>
|
||||
<:col :let={policy} label="GROUP">
|
||||
<.link class={link_style()} navigate={~p"/#{@account}/groups/#{policy.actor_group_id}"}>
|
||||
<.badge>
|
||||
<%= policy.actor_group.name %>
|
||||
</.badge>
|
||||
</.link>
|
||||
<.group account={@account} group={policy.actor_group} />
|
||||
</:col>
|
||||
<:col :let={policy} label="RESOURCE">
|
||||
<.link class={link_style()} navigate={~p"/#{@account}/resources/#{policy.resource_id}"}>
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule Web.Policies.New do
|
||||
def mount(params, _session, socket) do
|
||||
with {:ok, resources} <-
|
||||
Resources.list_resources(socket.assigns.subject, preload: [:gateway_groups]),
|
||||
{:ok, actor_groups} <- Actors.list_groups(socket.assigns.subject) do
|
||||
{:ok, actor_groups} <- Actors.list_groups(socket.assigns.subject, preload: :provider) do
|
||||
form = to_form(Policies.new_policy(%{}, socket.assigns.subject))
|
||||
|
||||
socket =
|
||||
@@ -64,8 +64,8 @@ defmodule Web.Policies.New do
|
||||
<.input
|
||||
field={@form[:actor_group_id]}
|
||||
label="Group"
|
||||
type="select"
|
||||
options={Enum.map(@actor_groups, fn g -> [key: g.name, value: g.id] end)}
|
||||
type="group_select"
|
||||
options={Web.Groups.Components.select_options(@actor_groups)}
|
||||
value={@form[:actor_group_id].value}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ defmodule Web.Policies.Show do
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
with {:ok, policy} <-
|
||||
Policies.fetch_policy_by_id(id, socket.assigns.subject,
|
||||
preload: [:actor_group, :resource, [created_by_identity: :actor]]
|
||||
preload: [actor_group: [:provider], resource: [], created_by_identity: :actor]
|
||||
),
|
||||
{:ok, flows} <-
|
||||
Flows.list_flows_for(policy, socket.assigns.subject,
|
||||
@@ -83,11 +83,7 @@ defmodule Web.Policies.Show do
|
||||
Group
|
||||
</:label>
|
||||
<:value>
|
||||
<.link navigate={~p"/#{@account}/groups/#{@policy.actor_group_id}"} class={link_style()}>
|
||||
<.badge>
|
||||
<%= @policy.actor_group.name %>
|
||||
</.badge>
|
||||
</.link>
|
||||
<.group account={@account} group={@policy.actor_group} />
|
||||
<span :if={not is_nil(@policy.actor_group.deleted_at)} class="text-red-600">
|
||||
(deleted)
|
||||
</span>
|
||||
|
||||
@@ -88,11 +88,7 @@ defmodule Web.Resources.Index do
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<.link class={link_style()} navigate={~p"/#{@account}/groups/#{group.id}"}>
|
||||
<.badge>
|
||||
<%= group.name %>
|
||||
</.badge>
|
||||
</.link>
|
||||
<.group account={@account} group={group} />
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
|
||||
@@ -138,14 +138,7 @@ defmodule Web.Resources.Show do
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<.link
|
||||
class={link_style()}
|
||||
navigate={~p"/#{@account}/groups/#{group.id}?#{@params}"}
|
||||
>
|
||||
<.badge>
|
||||
<%= group.name %>
|
||||
</.badge>
|
||||
</.link>
|
||||
<.group account={@account} group={group} />
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
|
||||
@@ -173,14 +173,7 @@ defmodule Web.Sites.Show do
|
||||
</:empty>
|
||||
|
||||
<:item :let={group}>
|
||||
<.link
|
||||
class={link_style()}
|
||||
navigate={~p"/#{@account}/groups/#{group.id}?site_id=#{@group}"}
|
||||
>
|
||||
<.badge>
|
||||
<%= group.name %>
|
||||
</.badge>
|
||||
</.link>
|
||||
<.group account={@account} group={group} />
|
||||
</:item>
|
||||
|
||||
<:tail :let={count}>
|
||||
|
||||
@@ -333,8 +333,7 @@ defmodule Web.Live.Actors.ShowTest do
|
||||
"#{synced_identity.provider.name} #{synced_identity.provider_identifier}",
|
||||
fn row ->
|
||||
refute row["actions"]
|
||||
assert row["created"] =~ "synced"
|
||||
assert row["created"] =~ "from #{synced_identity.provider.name}"
|
||||
assert row["created"] =~ "by #{synced_identity.provider.name} sync"
|
||||
assert row["last signed in"] == "never"
|
||||
end
|
||||
)
|
||||
|
||||
@@ -113,7 +113,9 @@ defmodule Web.Live.Groups.EditTest do
|
||||
group: group,
|
||||
conn: conn
|
||||
} do
|
||||
attrs = Fixtures.Actors.group_attrs()
|
||||
attrs =
|
||||
Fixtures.Actors.group_attrs()
|
||||
|> Map.delete(:type)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
@@ -140,7 +142,10 @@ defmodule Web.Live.Groups.EditTest do
|
||||
group: group,
|
||||
conn: conn
|
||||
} do
|
||||
attrs = Fixtures.Actors.group_attrs()
|
||||
attrs =
|
||||
Fixtures.Actors.group_attrs()
|
||||
|> Map.delete(:type)
|
||||
|
||||
Fixtures.Actors.create_group(name: attrs.name, account: account)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
@@ -162,7 +167,9 @@ defmodule Web.Live.Groups.EditTest do
|
||||
group: group,
|
||||
conn: conn
|
||||
} do
|
||||
attrs = Fixtures.Actors.group_attrs()
|
||||
attrs =
|
||||
Fixtures.Actors.group_attrs()
|
||||
|> Map.delete(:type)
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|
||||
@@ -57,7 +57,8 @@ defmodule Web.Live.Groups.NewTest do
|
||||
form = form(lv, "form")
|
||||
|
||||
assert find_inputs(form) == [
|
||||
"group[name]"
|
||||
"group[name]",
|
||||
"group[type]"
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -85,8 +85,8 @@ defmodule Web.Live.Groups.ShowTest do
|
||||
|> vertical_table_to_map()
|
||||
|
||||
assert table["name"] == group.name
|
||||
assert around_now?(table["source"])
|
||||
assert table["source"] =~ "by #{actor.name}"
|
||||
assert around_now?(table["created"])
|
||||
assert table["created"] =~ "by #{actor.name}"
|
||||
end
|
||||
|
||||
test "renders name of actor that created group", %{
|
||||
@@ -112,7 +112,7 @@ defmodule Web.Live.Groups.ShowTest do
|
||||
|> element("#group")
|
||||
|> render()
|
||||
|> vertical_table_to_map()
|
||||
|> Map.fetch!("source") =~ "by #{actor.name}"
|
||||
|> Map.fetch!("created") =~ "by #{actor.name}"
|
||||
end
|
||||
|
||||
test "renders provider that synced group", %{
|
||||
@@ -141,7 +141,7 @@ defmodule Web.Live.Groups.ShowTest do
|
||||
|> element("#group")
|
||||
|> render()
|
||||
|> vertical_table_to_map()
|
||||
|> Map.fetch!("source") =~ "Synced from #{provider.name} never"
|
||||
|> Map.fetch!("created") =~ "by #{provider.name} sync"
|
||||
end
|
||||
|
||||
test "renders group actors", %{
|
||||
@@ -239,7 +239,7 @@ defmodule Web.Live.Groups.ShowTest do
|
||||
|> live(~p"/#{account}/groups/#{group}")
|
||||
|
||||
assert lv
|
||||
|> element("a", "Edit Actors")
|
||||
|> element("#actors-empty a", "Edit Actors")
|
||||
|> render_click() ==
|
||||
{:error,
|
||||
{:live_redirect, %{to: ~p"/#{account}/groups/#{group}/edit_actors", kind: :push}}}
|
||||
|
||||
Reference in New Issue
Block a user