From beee8bd52e2dd597f87c375883c7bddc31773a6b Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Fri, 9 Feb 2024 16:07:42 -0600 Subject: [PATCH] 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: Screenshot 2024-02-08 at 18 30 25 2. The same is done across other pages which will help in case there is a duplicate group name (eg. manual and synced one): Screenshot 2024-02-08 at 18 31 59 Screenshot 2024-02-08 at 18 34 04 Screenshot 2024-02-08 at 18 34 22 Screenshot 2024-02-08 at 18 34 31 3. A bug was fixed and now we don't show synced groups whenever an actor is created: Screenshot 2024-02-08 at 18 32 22 4. We provide reason why groups are not editable: Screenshot 2024-02-08 at 18 33 29 Screenshot 2024-02-08 at 18 33 50 --- elixir/apps/domain/lib/domain/actors.ex | 101 ++++- .../domain/lib/domain/actors/actor/query.ex | 14 +- elixir/apps/domain/lib/domain/actors/group.ex | 5 +- .../lib/domain/actors/group/changeset.ex | 75 +++- .../domain/lib/domain/actors/group/query.ex | 22 +- .../domain/lib/domain/actors/group/sync.ex | 1 + .../lib/domain/actors/membership_rule.ex | 12 + .../actors/membership_rule/changeset.ex | 44 +++ elixir/apps/domain/lib/domain/auth.ex | 34 ++ .../domain/auth/adapters/google_workspace.ex | 2 +- .../domain/auth/adapters/openid_connect.ex | 26 +- .../domain/lib/domain/auth/identity/query.ex | 62 +++ .../domain/lib/domain/auth/identity/sync.ex | 3 + elixir/apps/domain/lib/domain/ops.ex | 6 + elixir/apps/domain/lib/domain/resources.ex | 22 +- elixir/apps/domain/lib/domain/validator.ex | 4 - ...actor_groups_type_and_membership_rules.exs | 13 + ...t_oidc_providers_provisioner_to_manual.exs | 7 + elixir/apps/domain/priv/repo/seeds.exs | 66 +++- .../apps/domain/test/domain/actors_test.exs | 355 +++++++++++++++++- elixir/apps/domain/test/domain/auth_test.exs | 137 ++++++- .../domain/test/domain/resources_test.exs | 12 + .../domain/test/support/fixtures/actors.ex | 24 +- .../apps/domain/test/support/fixtures/auth.ex | 2 +- .../web/lib/web/components/core_components.ex | 8 +- .../web/lib/web/components/form_components.ex | 26 +- .../lib/web/components/table_components.ex | 2 +- .../web/lib/web/live/actors/components.ex | 4 +- elixir/apps/web/lib/web/live/actors/edit.ex | 6 +- elixir/apps/web/lib/web/live/actors/index.ex | 10 +- .../web/live/actors/service_accounts/new.ex | 6 +- elixir/apps/web/lib/web/live/actors/show.ex | 34 +- .../apps/web/lib/web/live/actors/users/new.ex | 2 +- .../web/lib/web/live/groups/components.ex | 50 ++- elixir/apps/web/lib/web/live/groups/edit.ex | 2 +- .../web/lib/web/live/groups/edit_actors.ex | 2 +- elixir/apps/web/lib/web/live/groups/index.ex | 4 +- elixir/apps/web/lib/web/live/groups/new.ex | 3 +- elixir/apps/web/lib/web/live/groups/show.ex | 47 ++- .../apps/web/lib/web/live/policies/index.ex | 8 +- elixir/apps/web/lib/web/live/policies/new.ex | 6 +- elixir/apps/web/lib/web/live/policies/show.ex | 8 +- .../apps/web/lib/web/live/resources/index.ex | 6 +- .../apps/web/lib/web/live/resources/show.ex | 9 +- elixir/apps/web/lib/web/live/sites/show.ex | 9 +- .../web/test/web/live/actors/show_test.exs | 3 +- .../web/test/web/live/groups/edit_test.exs | 13 +- .../web/test/web/live/groups/new_test.exs | 3 +- .../web/test/web/live/groups/show_test.exs | 10 +- 49 files changed, 1123 insertions(+), 207 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/actors/membership_rule.ex create mode 100644 elixir/apps/domain/lib/domain/actors/membership_rule/changeset.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20240202170655_add_actor_groups_type_and_membership_rules.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20240202212135_set_oidc_providers_provisioner_to_manual.exs diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index 09006fd29..a4c8f8a42 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/actors/actor/query.ex b/elixir/apps/domain/lib/domain/actors/actor/query.ex index 92cf69928..5befce710 100644 --- a/elixir/apps/domain/lib/domain/actors/actor/query.ex +++ b/elixir/apps/domain/lib/domain/actors/actor/query.ex @@ -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() diff --git a/elixir/apps/domain/lib/domain/actors/group.ex b/elixir/apps/domain/lib/domain/actors/group.ex index fe0748b90..836d49781 100644 --- a/elixir/apps/domain/lib/domain/actors/group.ex +++ b/elixir/apps/domain/lib/domain/actors/group.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group/changeset.ex b/elixir/apps/domain/lib/domain/actors/group/changeset.ex index 39ab04021..fceb44d9a 100644 --- a/elixir/apps/domain/lib/domain/actors/group/changeset.ex +++ b/elixir/apps/domain/lib/domain/actors/group/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group/query.ex b/elixir/apps/domain/lib/domain/actors/group/query.ex index 3bd7ca5b4..b436b23d9 100644 --- a/elixir/apps/domain/lib/domain/actors/group/query.ex +++ b/elixir/apps/domain/lib/domain/actors/group/query.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/group/sync.ex b/elixir/apps/domain/lib/domain/actors/group/sync.ex index 7fe8f5b69..27e487862 100644 --- a/elixir/apps/domain/lib/domain/actors/group/sync.ex +++ b/elixir/apps/domain/lib/domain/actors/group/sync.ex @@ -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} -> diff --git a/elixir/apps/domain/lib/domain/actors/membership_rule.ex b/elixir/apps/domain/lib/domain/actors/membership_rule.ex new file mode 100644 index 000000000..e422a0909 --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/membership_rule.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/actors/membership_rule/changeset.ex b/elixir/apps/domain/lib/domain/actors/membership_rule/changeset.ex new file mode 100644 index 000000000..acb3bf38f --- /dev/null +++ b/elixir/apps/domain/lib/domain/actors/membership_rule/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index 4391fdfeb..ad2961801 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex index eb0e45815..9891c9e5c 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace.ex @@ -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 ] diff --git a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex index a8e348a10..7a56d8fc0 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/openid_connect.ex @@ -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, diff --git a/elixir/apps/domain/lib/domain/auth/identity/query.ex b/elixir/apps/domain/lib/domain/auth/identity/query.ex index e1af47c99..393d26a7b 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/query.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/query.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/auth/identity/sync.ex b/elixir/apps/domain/lib/domain/auth/identity/sync.ex index 0267f3157..d55c78c80 100644 --- a/elixir/apps/domain/lib/domain/auth/identity/sync.ex +++ b/elixir/apps/domain/lib/domain/auth/identity/sync.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/ops.ex b/elixir/apps/domain/lib/domain/ops.ex index da6a94f7c..5921652d8 100644 --- a/elixir/apps/domain/lib/domain/ops.ex +++ b/elixir/apps/domain/lib/domain/ops.ex @@ -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", diff --git a/elixir/apps/domain/lib/domain/resources.ex b/elixir/apps/domain/lib/domain/resources.ex index f34c5c380..23032079c 100644 --- a/elixir/apps/domain/lib/domain/resources.ex +++ b/elixir/apps/domain/lib/domain/resources.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/validator.ex b/elixir/apps/domain/lib/domain/validator.ex index aabe4f327..4fbd418cb 100644 --- a/elixir/apps/domain/lib/domain/validator.ex +++ b/elixir/apps/domain/lib/domain/validator.ex @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20240202170655_add_actor_groups_type_and_membership_rules.exs b/elixir/apps/domain/priv/repo/migrations/20240202170655_add_actor_groups_type_and_membership_rules.exs new file mode 100644 index 000000000..0c7ae66a6 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240202170655_add_actor_groups_type_and_membership_rules.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/migrations/20240202212135_set_oidc_providers_provisioner_to_manual.exs b/elixir/apps/domain/priv/repo/migrations/20240202212135_set_oidc_providers_provisioner_to_manual.exs new file mode 100644 index 000000000..084d06218 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20240202212135_set_oidc_providers_provisioner_to_manual.exs @@ -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 diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index d80963214..f4ca6474e 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index f33298898..221ff0fb9 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -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, diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 14fe0ed96..94b3fa27b 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -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, diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index d17af34d8..73cffdc6b 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/actors.ex b/elixir/apps/domain/test/support/fixtures/actors.ex index 76f5ad054..7eeec7fe3 100644 --- a/elixir/apps/domain/test/support/fixtures/actors.ex +++ b/elixir/apps/domain/test/support/fixtures/actors.ex @@ -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 diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 11e13461b..4da2295c5 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -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() diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index 7ad3284e9..bd34bd8c8 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -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 %> - + 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" ]} > diff --git a/elixir/apps/web/lib/web/components/form_components.ex b/elixir/apps/web/lib/web/components/form_components.ex index 0b1edbb8b..3f74b9acd 100644 --- a/elixir/apps/web/lib/web/components/form_components.ex +++ b/elixir/apps/web/lib/web/components/form_components.ex @@ -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""" +
+ <.label for={@id}><%= @label %> + + <.error :for={msg <- @errors} data-validation-error-for={@name}><%= msg %> +
+ """ + end + def input(%{type: "select"} = assigns) do ~H"""
diff --git a/elixir/apps/web/lib/web/components/table_components.ex b/elixir/apps/web/lib/web/components/table_components.ex index 627ebc0b7..a4bd8b61b 100644 --- a/elixir/apps/web/lib/web/components/table_components.ex +++ b/elixir/apps/web/lib/web/components/table_components.ex @@ -136,7 +136,7 @@ defmodule Web.TableComponents do /> -
+
<%= render_slot(@empty) %>
diff --git a/elixir/apps/web/lib/web/live/actors/components.ex b/elixir/apps/web/lib/web/live/actors/components.ex index 9239a383d..49a05a67c 100644 --- a/elixir/apps/web/lib/web/live/actors/components.ex +++ b/elixir/apps/web/lib/web/live/actors/components.ex @@ -78,12 +78,12 @@ defmodule Web.Actors.Components do
<.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" />

diff --git a/elixir/apps/web/lib/web/live/actors/edit.ex b/elixir/apps/web/lib/web/live/actors/edit.ex index 84ddce68d..2e6935966 100644 --- a/elixir/apps/web/lib/web/live/actors/edit.ex +++ b/elixir/apps/web/lib/web/live/actors/edit.ex @@ -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, diff --git a/elixir/apps/web/lib/web/live/actors/index.ex b/elixir/apps/web/lib/web/live/actors/index.ex index 91264188c..ddd527a67 100644 --- a/elixir/apps/web/lib/web/live/actors/index.ex +++ b/elixir/apps/web/lib/web/live/actors/index.ex @@ -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 <:item :let={group}> - <.group - account={@account} - group={%{group | provider: Map.get(@providers_by_id, group.provider_id)}} - /> + <.group account={@account} group={group} /> <:tail :let={count}> diff --git a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex index f096acff2..3f7806c17 100644 --- a/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex +++ b/elixir/apps/web/lib/web/live/actors/service_accounts/new.ex @@ -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, diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index 0446dfafe..738ccd2f8 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -170,22 +170,24 @@ defmodule Web.Actors.Show do

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 - - 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 - - to authenticate this user. + + <.link + class={[link_style()]} + navigate={~p"/#{@account}/actors/service_accounts/#{@actor}/new_identity"} + > + Create a token + + to authenticate this service account. + + + <.link + class={[link_style()]} + navigate={~p"/#{@account}/actors/users/#{@actor}/new_identity"} + > + Create an identity + + to authenticate this user. +
diff --git a/elixir/apps/web/lib/web/live/actors/users/new.ex b/elixir/apps/web/lib/web/live/actors/users/new.ex index d14142209..4a2dbccf3 100644 --- a/elixir/apps/web/lib/web/live/actors/users/new.ex +++ b/elixir/apps/web/lib/web/live/actors/users/new.ex @@ -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 = diff --git a/elixir/apps/web/lib/web/live/groups/components.ex b/elixir/apps/web/lib/web/live/groups/components.ex index fd02e78ff..9ff62add8 100644 --- a/elixir/apps/web/lib/web/live/groups/components.ex +++ b/elixir/apps/web/lib/web/live/groups/components.ex @@ -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""" - - Synced from - <.link - class="text-accent-500 hover:underline" - navigate={Web.Settings.IdentityProviders.Components.view_provider(@account, @group.provider)} - > - <%= @group.provider.name %> - - <.relative_datetime datetime={@group.provider.last_synced_at} /> - - - <.created_by account={@account} schema={@group} /> - - """ + 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 diff --git a/elixir/apps/web/lib/web/live/groups/edit.ex b/elixir/apps/web/lib/web/live/groups/edit.ex index 9b04a3e6d..65af0b089 100644 --- a/elixir/apps/web/lib/web/live/groups/edit.ex +++ b/elixir/apps/web/lib/web/live/groups/edit.ex @@ -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 = diff --git a/elixir/apps/web/lib/web/live/groups/edit_actors.ex b/elixir/apps/web/lib/web/live/groups/edit_actors.ex index 8cedc3d9c..4962bf868 100644 --- a/elixir/apps/web/lib/web/live/groups/edit_actors.ex +++ b/elixir/apps/web/lib/web/live/groups/edit_actors.ex @@ -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) diff --git a/elixir/apps/web/lib/web/live/groups/index.ex b/elixir/apps/web/lib/web/live/groups/index.ex index cc71c94bc..0024e4bd3 100644 --- a/elixir/apps/web/lib/web/live/groups/index.ex +++ b/elixir/apps/web/lib/web/live/groups/index.ex @@ -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 <:col :let={group} label="SOURCE" sortable="false"> - <.source account={@account} group={group} /> + <.created_by account={@account} schema={group} /> <:empty>
@@ -95,7 +94,6 @@ defmodule Web.Groups.Index do
-
diff --git a/elixir/apps/web/lib/web/live/groups/new.ex b/elixir/apps/web/lib/web/live/groups/new.ex index 11f52fce4..70a3bdbb6 100644 --- a/elixir/apps/web/lib/web/live/groups/new.ex +++ b/elixir/apps/web/lib/web/live/groups/new.ex @@ -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>
<.form for={@form} phx-change={:change} phx-submit={:submit}> + <.input type="hidden" field={@form[:type]} value="static" />
<.input diff --git a/elixir/apps/web/lib/web/live/groups/show.ex b/elixir/apps/web/lib/web/live/groups/show.ex index 90a7d7928..45687bb1e 100644 --- a/elixir/apps/web/lib/web/live/groups/show.ex +++ b/elixir/apps/web/lib/web/live/groups/show.ex @@ -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 <: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 <: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 + :if={ + Actors.group_managed?(@group) and + Enum.any?(@group.membership_rules, &(&1 == %Actors.MembershipRule{operator: true})) + } + kind={:info} + > +

This group is managed by Firezone and cannot be edited.

+

It will contain all actors with at least one authentication identity.

+ + <.flash :if={Actors.group_synced?(@group)} kind={:info}> + This group is synced from an external source and cannot be edited. + + <.vertical_table id="group"> <.vertical_table_row> <:label>Name <:value><%= @group.name %> <.vertical_table_row> - <:label>Source - <:value> - <.source account={@account} group={@group} /> - + <:label>Created + <:value><.created_by account={@account} schema={@group} /> @@ -62,7 +82,7 @@ defmodule Web.Groups.Show do <:title>Actors <: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
- No actors in group + There are no actors in this group.
<.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
-
- No actors in synced group -
- <.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" diff --git a/elixir/apps/web/lib/web/live/policies/index.ex b/elixir/apps/web/lib/web/live/policies/index.ex index e6f63e37f..f631f0101 100644 --- a/elixir/apps/web/lib/web/live/policies/index.ex +++ b/elixir/apps/web/lib/web/live/policies/index.ex @@ -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 <:col :let={policy} label="GROUP"> - <.link class={link_style()} navigate={~p"/#{@account}/groups/#{policy.actor_group_id}"}> - <.badge> - <%= policy.actor_group.name %> - - + <.group account={@account} group={policy.actor_group} /> <:col :let={policy} label="RESOURCE"> <.link class={link_style()} navigate={~p"/#{@account}/resources/#{policy.resource_id}"}> diff --git a/elixir/apps/web/lib/web/live/policies/new.ex b/elixir/apps/web/lib/web/live/policies/new.ex index 1d031a122..a216a9557 100644 --- a/elixir/apps/web/lib/web/live/policies/new.ex +++ b/elixir/apps/web/lib/web/live/policies/new.ex @@ -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 /> diff --git a/elixir/apps/web/lib/web/live/policies/show.ex b/elixir/apps/web/lib/web/live/policies/show.ex index f192bcdef..373fc9eb5 100644 --- a/elixir/apps/web/lib/web/live/policies/show.ex +++ b/elixir/apps/web/lib/web/live/policies/show.ex @@ -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 <:value> - <.link navigate={~p"/#{@account}/groups/#{@policy.actor_group_id}"} class={link_style()}> - <.badge> - <%= @policy.actor_group.name %> - - + <.group account={@account} group={@policy.actor_group} /> (deleted) diff --git a/elixir/apps/web/lib/web/live/resources/index.ex b/elixir/apps/web/lib/web/live/resources/index.ex index 8c25c288f..78a670ce3 100644 --- a/elixir/apps/web/lib/web/live/resources/index.ex +++ b/elixir/apps/web/lib/web/live/resources/index.ex @@ -88,11 +88,7 @@ defmodule Web.Resources.Index do <:item :let={group}> - <.link class={link_style()} navigate={~p"/#{@account}/groups/#{group.id}"}> - <.badge> - <%= group.name %> - - + <.group account={@account} group={group} /> <:tail :let={count}> diff --git a/elixir/apps/web/lib/web/live/resources/show.ex b/elixir/apps/web/lib/web/live/resources/show.ex index 7332be966..1b8154d15 100644 --- a/elixir/apps/web/lib/web/live/resources/show.ex +++ b/elixir/apps/web/lib/web/live/resources/show.ex @@ -138,14 +138,7 @@ defmodule Web.Resources.Show do <:item :let={group}> - <.link - class={link_style()} - navigate={~p"/#{@account}/groups/#{group.id}?#{@params}"} - > - <.badge> - <%= group.name %> - - + <.group account={@account} group={group} /> <:tail :let={count}> diff --git a/elixir/apps/web/lib/web/live/sites/show.ex b/elixir/apps/web/lib/web/live/sites/show.ex index 4b3205ad4..d5b96fcb5 100644 --- a/elixir/apps/web/lib/web/live/sites/show.ex +++ b/elixir/apps/web/lib/web/live/sites/show.ex @@ -173,14 +173,7 @@ defmodule Web.Sites.Show do <:item :let={group}> - <.link - class={link_style()} - navigate={~p"/#{@account}/groups/#{group.id}?site_id=#{@group}"} - > - <.badge> - <%= group.name %> - - + <.group account={@account} group={group} /> <:tail :let={count}> diff --git a/elixir/apps/web/test/web/live/actors/show_test.exs b/elixir/apps/web/test/web/live/actors/show_test.exs index 9e223a963..579887367 100644 --- a/elixir/apps/web/test/web/live/actors/show_test.exs +++ b/elixir/apps/web/test/web/live/actors/show_test.exs @@ -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 ) diff --git a/elixir/apps/web/test/web/live/groups/edit_test.exs b/elixir/apps/web/test/web/live/groups/edit_test.exs index bdef166ae..f34a2e283 100644 --- a/elixir/apps/web/test/web/live/groups/edit_test.exs +++ b/elixir/apps/web/test/web/live/groups/edit_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/groups/new_test.exs b/elixir/apps/web/test/web/live/groups/new_test.exs index 4e0d70654..90ff307eb 100644 --- a/elixir/apps/web/test/web/live/groups/new_test.exs +++ b/elixir/apps/web/test/web/live/groups/new_test.exs @@ -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 diff --git a/elixir/apps/web/test/web/live/groups/show_test.exs b/elixir/apps/web/test/web/live/groups/show_test.exs index 11a7664b9..719bc269f 100644 --- a/elixir/apps/web/test/web/live/groups/show_test.exs +++ b/elixir/apps/web/test/web/live/groups/show_test.exs @@ -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}}}