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:
Andrew Dryga
2024-02-09 16:07:42 -06:00
committed by GitHub
parent 4c0b685978
commit beee8bd52e
49 changed files with 1123 additions and 207 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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