feat(portal): Allow bulk-deleting synced actors (#6352)

Closes #6301
Closes #6217

<img width="1728" alt="Screenshot 2024-08-19 at 12 19 16 PM"
src="https://github.com/user-attachments/assets/0c1b570d-9ea9-413a-a8b5-febcd6d37072">
This commit is contained in:
Andrew Dryga
2024-08-20 13:05:19 -06:00
committed by GitHub
parent 7593dba7fb
commit a5342256c3
6 changed files with 243 additions and 11 deletions

View File

@@ -352,6 +352,13 @@ defmodule Domain.Actors do
|> Repo.aggregate(:count)
end
def count_synced_actors_for_provider(%Auth.Provider{} = provider) do
Actor.Query.not_deleted()
|> Actor.Query.by_provider_id(provider.id)
|> Actor.Query.by_stale_for_provider(provider.id)
|> Repo.aggregate(:count)
end
def fetch_actor_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()),
true <- Repo.valid_uuid?(id) do
@@ -569,6 +576,22 @@ defmodule Domain.Actors do
end
end
def delete_stale_synced_actors_for_provider(
%Auth.Provider{} = provider,
%Auth.Subject{} = subject
) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_actors_permission()) do
Actor.Query.not_deleted()
|> Authorizer.for_subject(subject)
|> Actor.Query.by_provider_id(provider.id)
|> Actor.Query.by_stale_for_provider(provider.id)
|> Repo.all()
|> Enum.each(fn actor ->
{:ok, _actor} = delete_actor(actor, subject)
end)
end
end
def actor_synced?(%Actor{last_synced_at: nil}), do: false
def actor_synced?(%Actor{}), do: true

View File

@@ -30,6 +30,28 @@ defmodule Domain.Actors.Actor.Query do
where(queryable, [actors: actors], actors.account_id == ^account_id)
end
def by_provider_id(queryable, provider_id) do
queryable
|> join(:right, [actors: actors], identities in ^Domain.Auth.Identity.Query.all(),
on: identities.actor_id == actors.id,
as: :all_identities
)
|> where(
[all_identities: all_identities],
all_identities.provider_id == ^provider_id
)
end
def by_stale_for_provider(queryable, provider_id) do
subquery =
Domain.Auth.Identity.Query.all()
|> where([identities: identities], identities.actor_id == parent_as(:actors).id)
|> where([identities: identities], identities.provider_id != ^provider_id)
queryable
|> where([actors: actors], not exists(subquery))
end
def by_type(queryable, {:in, types}) do
where(queryable, [actors: actors], actors.type in ^types)
end

View File

@@ -134,12 +134,16 @@ admin_actor_email = "firezone@localhost.local"
name: "Firezone Unprivileged"
})
for i <- 1..10 do
Actors.create_actor(account, %{
type: :account_user,
name: "Firezone Unprivileged #{i}"
})
end
other_actors =
for i <- 1..10 do
{:ok, actor} =
Actors.create_actor(account, %{
type: :account_user,
name: "Firezone Unprivileged #{i}"
})
actor
end
{:ok, admin_actor} =
Actors.create_actor(account, %{
@@ -203,6 +207,25 @@ admin_actor_oidc_identity
)
|> Repo.update!()
for actor <- other_actors do
email = "user-#{System.unique_integer([:positive, :monotonic])}@localhost.local"
{:ok, identity} =
Auth.create_identity(actor, oidc_provider, %{
provider_identifier: email,
provider_identifier_confirmation: email
})
identity
|> Ecto.Changeset.change(
created_by: :provider,
provider_id: oidc_provider.id,
provider_identifier: email,
provider_state: %{"claims" => %{"email" => email, "group" => "users"}}
)
|> Repo.update!()
end
# Other Account Users
other_unprivileged_actor_email = "other-unprivileged-1@localhost.local"
other_admin_actor_email = "other@localhost.local"

View File

@@ -2121,6 +2121,63 @@ defmodule Domain.ActorsTest do
end
end
describe "count_synced_actors_for_provider/1" do
test "returns 0 when there are no actors" do
account = Fixtures.Accounts.create_account()
provider = Fixtures.Auth.create_userpass_provider(account: account)
assert count_synced_actors_for_provider(provider) == 0
end
test "returns 0 when there are no synced actors" do
account = Fixtures.Accounts.create_account()
provider = Fixtures.Auth.create_userpass_provider(account: account)
Fixtures.Actors.create_actor(type: :account_admin_user, account: account)
assert count_synced_actors_for_provider(provider) == 0
end
test "returns count of synced actors owned only by the given provider" do
account = Fixtures.Accounts.create_account()
{provider, _bypass} =
Fixtures.Auth.start_and_create_openid_connect_provider(account: account)
actor1 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor1, provider: provider)
actor2 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor2, provider: provider)
actor3 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor3)
actor4 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor4, provider: provider)
Fixtures.Auth.create_identity(account: account, actor: actor4)
assert count_synced_actors_for_provider(provider) == 2
end
end
describe "fetch_actor_by_id/3" do
test "returns error when actor is not found" do
subject = Fixtures.Auth.create_subject()
@@ -3145,6 +3202,72 @@ defmodule Domain.ActorsTest do
end
end
describe "delete_stale_synced_actors_for_provider/2" do
test "deletes actors synced with only the given provider" do
account = Fixtures.Accounts.create_account()
subject = Fixtures.Auth.create_subject(account: account)
{provider, _bypass} =
Fixtures.Auth.start_and_create_openid_connect_provider(account: account)
actor1 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor1, provider: provider)
actor2 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor2, provider: provider)
actor3 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor3)
actor4 =
Fixtures.Actors.create_actor(
type: :account_admin_user,
account: account
)
Fixtures.Auth.create_identity(account: account, actor: actor4, provider: provider)
Fixtures.Auth.create_identity(account: account, actor: actor4)
assert delete_stale_synced_actors_for_provider(provider, subject) == :ok
not_deleted_actors = Repo.all(Actors.Actor.Query.not_deleted())
not_deleted_actor_ids = not_deleted_actors |> Enum.map(& &1.id) |> Enum.sort()
assert not_deleted_actor_ids == Enum.sort([actor4.id, actor3.id, subject.actor.id])
end
test "returns error when subject cannot delete actors" do
account = Fixtures.Accounts.create_account()
{provider, _bypass} =
Fixtures.Auth.start_and_create_openid_connect_provider(account: account)
subject =
Fixtures.Auth.create_subject(account: account)
|> Fixtures.Auth.remove_permissions()
assert delete_stale_synced_actors_for_provider(provider, subject) ==
{:error,
{:unauthorized,
reason: :missing_permissions,
missing_permissions: [Actors.Authorizer.manage_actors_permission()]}}
end
end
describe "actor_synced?/1" do
test "returns true when actor is synced" do
actor = Fixtures.Actors.create_actor()

View File

@@ -1,16 +1,22 @@
defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do
use Web, :live_view
import Web.Settings.IdentityProviders.Components
alias Domain.Auth
alias Domain.{Auth, Actors}
def mount(%{"provider_id" => provider_id}, _session, socket) do
with {:ok, provider} <-
Auth.fetch_provider_by_id(provider_id, socket.assigns.subject,
preload: [created_by_identity: [:actor]]
) do
safe_to_delete_actors_count =
if is_nil(provider.deleted_at),
do: 0,
else: Actors.count_synced_actors_for_provider(provider)
socket =
assign(socket,
provider: provider,
safe_to_delete_actors_count: safe_to_delete_actors_count,
page_title: "Identity Provider #{provider.name}"
)
@@ -103,6 +109,32 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do
</.header>
<.flash_group flash={@flash} />
<.flash
:if={not is_nil(@provider.deleted_at) and @safe_to_delete_actors_count > 0}
kind={:warning}
>
You have <%= @safe_to_delete_actors_count %> Actor(s) that were synced from this provider and do not have any other identities.
<.button_with_confirmation
id="delete_stale_actors"
style="danger"
icon="hero-trash-solid"
on_confirm="delete_stale_actors"
class="mt-4"
>
<:dialog_title>Delete Stale Actors</:dialog_title>
<:dialog_content>
Are you sure you want to delete all Actors that were synced synced from this provider and do not have any other identities?
</:dialog_content>
<:dialog_confirm_button>
Delete Actors
</:dialog_confirm_button>
<:dialog_cancel_button>
Cancel
</:dialog_cancel_button>
Delete Actors
</.button_with_confirmation>
</.flash>
<div class="bg-white overflow-hidden">
<.vertical_table id="provider">
<.vertical_table_row>
@@ -165,7 +197,7 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do
<:dialog_title>Delete Identity Provider</:dialog_title>
<:dialog_content>
Are you sure you want to delete this provider? This will remove <strong>all</strong>
Actors and Groups associated with this provider.
Identities, Groups and Policies associated with this provider.
</:dialog_content>
<:dialog_confirm_button>
Delete Identity Provider
@@ -181,10 +213,19 @@ defmodule Web.Settings.IdentityProviders.OpenIDConnect.Show do
end
def handle_event("delete", _params, socket) do
{:ok, _provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject)
{:ok, provider} = Auth.delete_provider(socket.assigns.provider, socket.assigns.subject)
{:noreply, push_navigate(socket, to: view_provider(socket.assigns.account, provider))}
end
def handle_event("delete_stale_actors", _params, socket) do
:ok =
Actors.delete_stale_synced_actors_for_provider(
socket.assigns.provider,
socket.assigns.subject
)
{:noreply,
push_navigate(socket, to: ~p"/#{socket.assigns.account}/settings/identity_providers")}
push_navigate(socket, to: view_provider(socket.assigns.account, socket.assigns.provider))}
end
def handle_event("enable", _params, socket) do

View File

@@ -160,7 +160,7 @@ defmodule Web.Live.Settings.IdentityProviders.OpenIDConnect.ShowTest do
|> element("button[type=submit]", "Delete Identity Provider")
|> render_click()
assert_redirected(lv, ~p"/#{account}/settings/identity_providers")
assert_redirected(lv, ~p"/#{account}/settings/identity_providers/openid_connect/#{provider}")
assert Repo.get(Domain.Auth.Provider, provider.id).deleted_at
end