diff --git a/elixir/apps/domain/lib/domain/actors.ex b/elixir/apps/domain/lib/domain/actors.ex index 2dc83b11f..37044eaef 100644 --- a/elixir/apps/domain/lib/domain/actors.ex +++ b/elixir/apps/domain/lib/domain/actors.ex @@ -121,6 +121,33 @@ defmodule Domain.Actors do end end + def peek_actor_clients(actors, limit, %Auth.Subject{} = subject) do + with :ok <- + Auth.ensure_has_permissions(subject, Clients.Authorizer.manage_clients_permission()) do + ids = actors |> Enum.map(& &1.id) |> Enum.uniq() + + {:ok, peek} = + Actor.Query.not_deleted() + |> Actor.Query.by_id({:in, ids}) + |> Actor.Query.preload_few_clients_for_each_actor(limit) + |> Authorizer.for_subject(subject) + |> Repo.peek(actors) + + group_by_ids = + Enum.flat_map(peek, fn {_id, %{items: items}} -> items end) + |> Clients.preload_clients_presence() + |> 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 + def sync_provider_groups(%Auth.Provider{} = provider, attrs_list) do Group.Sync.sync_provider_groups(provider, attrs_list) end diff --git a/elixir/apps/domain/lib/domain/actors/actor/query.ex b/elixir/apps/domain/lib/domain/actors/actor/query.ex index 5bc14f6ec..68bd5520e 100644 --- a/elixir/apps/domain/lib/domain/actors/actor/query.ex +++ b/elixir/apps/domain/lib/domain/actors/actor/query.ex @@ -66,6 +66,37 @@ defmodule Domain.Actors.Actor.Query do # Preloads + def preload_few_clients_for_each_actor(queryable, limit) do + queryable + |> with_joined_clients(limit) + |> with_joined_client_counts() + |> select([actors: actors, clients: clients, client_counts: client_counts], %{ + id: actors.id, + count: client_counts.count, + item: clients + }) + end + + def with_joined_clients(queryable, limit) do + subquery = + Domain.Clients.Client.Query.not_deleted() + |> where([clients: clients], clients.actor_id == parent_as(:actors).id) + |> order_by([clients: clients], desc: clients.last_seen_at) + |> limit(^limit) + + join(queryable, :cross_lateral, [actors: actors], clients in subquery(subquery), as: :clients) + end + + def with_joined_client_counts(queryable) do + subquery = + Domain.Clients.Client.Query.count_clients_by_actor_id() + |> where([clients: clients], clients.actor_id == parent_as(:actors).id) + + join(queryable, :cross_lateral, [actors: actors], client_counts in subquery(subquery), + as: :client_counts + ) + end + def preload_few_groups_for_each_actor(queryable, limit) do queryable |> with_joined_memberships(limit) @@ -170,7 +201,8 @@ defmodule Domain.Actors.Actor.Query do @impl Domain.Repo.Query def preloads, do: [ - last_seen_at: &Domain.Actors.preload_last_seen_at/1 + last_seen_at: &Domain.Actors.preload_last_seen_at/1, + clients: {nil, Domain.Clients.Client.Query.preloads()} ] @impl Domain.Repo.Query diff --git a/elixir/apps/domain/lib/domain/clients/client/query.ex b/elixir/apps/domain/lib/domain/clients/client/query.ex index 23c42d32e..1fdb5f5ff 100644 --- a/elixir/apps/domain/lib/domain/clients/client/query.ex +++ b/elixir/apps/domain/lib/domain/clients/client/query.ex @@ -48,6 +48,15 @@ defmodule Domain.Clients.Client.Query do |> distinct(true) end + def count_clients_by_actor_id(queryable \\ not_deleted()) do + queryable + |> group_by([clients: clients], clients.actor_id) + |> select([clients: clients], %{ + actor_id: clients.actor_id, + count: count(clients.id) + }) + end + def returning_not_deleted(queryable) do select(queryable, [clients: clients], clients) end diff --git a/elixir/apps/domain/lib/domain/repo/paginator.ex b/elixir/apps/domain/lib/domain/repo/paginator.ex index 142dee252..f5ba27281 100644 --- a/elixir/apps/domain/lib/domain/repo/paginator.ex +++ b/elixir/apps/domain/lib/domain/repo/paginator.ex @@ -66,8 +66,8 @@ defmodule Domain.Repo.Paginator do def query(queryable, paginator_opts) do queryable - |> order_by_cursor_fields(paginator_opts) |> maybe_query_page(paginator_opts) + |> order_by_cursor_fields(paginator_opts) |> limit_page_size(paginator_opts) end diff --git a/elixir/apps/domain/priv/repo/seeds.exs b/elixir/apps/domain/priv/repo/seeds.exs index ba8dbc4c8..c8cbdba28 100644 --- a/elixir/apps/domain/priv/repo/seeds.exs +++ b/elixir/apps/domain/priv/repo/seeds.exs @@ -221,14 +221,58 @@ for actor <- other_actors do 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!() + identity = + identity + |> Ecto.Changeset.change( + created_by: :provider, + provider_id: oidc_provider.id, + provider_identifier: email, + provider_state: %{"claims" => %{"email" => email, "group" => "users"}} + ) + |> Repo.update!() + + context = %Auth.Context{ + type: :browser, + user_agent: "Windows/10.0.22631 seeds/1", + remote_ip: {172, 28, 0, 100}, + remote_ip_location_region: "UA", + remote_ip_location_city: "Kyiv", + remote_ip_location_lat: 50.4333, + remote_ip_location_lon: 30.5167 + } + + {:ok, token} = + Auth.create_token(identity, context, "n", nil) + + {:ok, subject} = Auth.build_subject(token, context) + + count = Enum.random([1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 240]) + + for i <- 0..count do + user_agent = + Enum.random([ + "iOS/12.7 (iPhone) connlib/1.5.0", + "Android/14 connlib/1.4.1", + "Windows/10.0.22631 connlib/1.3.412", + "Ubuntu/22.4.0 connlib/1.2.2" + ]) + + client_name = String.split(user_agent, "/") |> List.first() + + {:ok, _client} = + Domain.Clients.upsert_client( + %{ + name: "My #{client_name} #{i}", + external_id: Ecto.UUID.generate(), + public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(), + identifier_for_vendor: Ecto.UUID.generate() + }, + %{ + subject + | context: %{subject.context | user_agent: user_agent} + } + ) + end end # Other Account Users @@ -432,7 +476,7 @@ end {:ok, finance_group} = Actors.create_group(%{name: "Finance", type: :static}, admin_subject) {:ok, synced_group} = - Actors.create_group(%{name: "Synced Group with long name", type: :static}, admin_subject) + Actors.create_group(%{name: "Group:Synced Group with long name", type: :static}, admin_subject) for group <- [eng_group, finance_group, synced_group] do IO.puts(" Name: #{group.name} ID: #{group.id}") @@ -476,6 +520,25 @@ oidc_provider |> Ecto.Changeset.change(last_synced_at: DateTime.utc_now()) |> Repo.update!() +for name <- [ + "Group:gcp-logging-viewers", + "Group:gcp-security-admins", + "Group:gcp-organization-admins", + "OU:Admins", + "OU:Product", + "Group:Engineering", + "Group:gcp-developers" + ] do + {:ok, group} = Actors.create_group(%{name: name, type: :static}, admin_subject) + + group + |> Repo.preload(:memberships) + |> Actors.update_group( + %{memberships: [%{actor_id: admin_subject.actor.id}]}, + admin_subject + ) +end + IO.puts("") {:ok, global_relay_group} = diff --git a/elixir/apps/domain/test/domain/actors_test.exs b/elixir/apps/domain/test/domain/actors_test.exs index b8fc1530b..8500f10c4 100644 --- a/elixir/apps/domain/test/domain/actors_test.exs +++ b/elixir/apps/domain/test/domain/actors_test.exs @@ -2,6 +2,7 @@ defmodule Domain.ActorsTest do use Domain.DataCase, async: true import Domain.Actors alias Domain.Auth + alias Domain.Clients alias Domain.Actors describe "fetch_groups_count_grouped_by_provider_id/1" do @@ -585,6 +586,142 @@ defmodule Domain.ActorsTest do end end + describe "peek_actor_clients/3" do + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + identity: identity, + subject: subject + } + end + + test "returns count of clients per actor and first 3 clients", %{ + account: account, + subject: subject + } do + actor1 = Fixtures.Actors.create_actor(account: account) + Fixtures.Clients.create_client(account: account, actor: actor1) + Fixtures.Clients.create_client(account: account, actor: actor1) + Fixtures.Clients.create_client(account: account, actor: actor1) + Fixtures.Clients.create_client(account: account, actor: actor1) + + actor2 = Fixtures.Actors.create_actor(account: account) + + assert {:ok, peek} = peek_actor_clients([actor1, actor2], 3, subject) + + assert length(Map.keys(peek)) == 2 + + assert peek[actor1.id].count == 4 + assert length(peek[actor1.id].items) == 3 + assert [%Clients.Client{} | _] = peek[actor1.id].items + + assert peek[actor2.id].count == 0 + assert Enum.empty?(peek[actor2.id].items) + end + + test "preloads client presence", %{ + account: account, + subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) + client = Fixtures.Clients.create_client(account: account, actor: actor) + Domain.Clients.connect_client(client) + + assert {:ok, peek} = peek_actor_clients([actor], 3, subject) + assert [%Clients.Client{} = client] = peek[actor.id].items + assert client.online? + end + + test "returns count of clients per actor and first LIMIT clients", %{ + account: account, + subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) + Fixtures.Clients.create_client(account: account, actor: actor) + Fixtures.Clients.create_client(account: account, actor: actor) + + other_actor = Fixtures.Actors.create_actor(account: account) + Fixtures.Clients.create_client(account: account, actor: other_actor) + + assert {:ok, peek} = peek_actor_clients([actor], 1, subject) + assert length(peek[actor.id].items) == 1 + assert Enum.count(peek) == 1 + end + + test "ignores deleted clients", %{ + account: account, + subject: subject + } do + actor = Fixtures.Actors.create_actor(account: account) + + Fixtures.Clients.create_client(account: account, actor: actor) + |> Fixtures.Clients.delete_client() + + assert {:ok, peek} = peek_actor_clients([actor], 3, subject) + assert peek[actor.id].count == 0 + assert Enum.empty?(peek[actor.id].items) + end + + test "ignores other clients", %{ + account: account, + subject: subject + } do + Fixtures.Clients.create_client(account: account) + Fixtures.Clients.create_client(account: account) + + actor = Fixtures.Actors.create_actor(account: account) + + assert {:ok, peek} = peek_actor_clients([actor], 1, subject) + assert peek[actor.id].count == 0 + assert Enum.empty?(peek[actor.id].items) + end + + test "returns empty map on empty actors", %{subject: subject} do + assert peek_actor_clients([], 1, subject) == {:ok, %{}} + end + + test "returns empty map on empty clients", %{account: account, subject: subject} do + actor = Fixtures.Actors.create_actor(account: account) + + assert {:ok, peek} = peek_actor_clients([actor], 3, subject) + + assert length(Map.keys(peek)) == 1 + assert peek[actor.id].count == 0 + assert Enum.empty?(peek[actor.id].items) + end + + test "does not allow peeking into other accounts", %{ + subject: subject + } do + other_account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(account: other_account) + Fixtures.Clients.create_client(account: other_account, actor: actor) + + assert {:ok, peek} = peek_actor_clients([actor], 3, subject) + assert Map.has_key?(peek, actor.id) + assert peek[actor.id].count == 0 + assert Enum.empty?(peek[actor.id].items) + end + + test "returns error when subject has no permission to manage groups", %{ + subject: subject + } do + subject = Fixtures.Auth.remove_permissions(subject) + + assert peek_actor_clients([], 3, subject) == + {:error, + {:unauthorized, + reason: :missing_permissions, + missing_permissions: [Domain.Clients.Authorizer.manage_clients_permission()]}} + end + end + describe "sync_provider_groups/2" do setup do account = Fixtures.Accounts.create_account() diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index b9937c1cd..827ed0b7e 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -967,13 +967,14 @@ defmodule Web.CoreComponents do attr :datetime, DateTime, default: nil attr :relative_to, DateTime, required: false attr :negative_class, :string, default: "" + attr :popover, :boolean, default: true def relative_datetime(assigns) do assigns = assign_new(assigns, :relative_to, fn -> DateTime.utc_now() end) ~H""" - <.popover :if={not is_nil(@datetime)}> + <.popover :if={not is_nil(@datetime) and @popover}> <:target> + + <%= Cldr.DateTime.Relative.to_string!(@datetime, Web.CLDR, relative_to: @relative_to) + |> String.capitalize() %> + Never @@ -1056,6 +1061,17 @@ defmodule Web.CoreComponents do """ end + @doc """ + Renders online or offline status using an `online?` field of the schema. + """ + attr :schema, :any, required: true + + def online_icon(assigns) do + ~H""" + + """ + end + attr :navigate, :string, required: true attr :connected?, :boolean, required: true attr :type, :string, required: true @@ -1459,10 +1475,11 @@ defmodule Web.CoreComponents do """ attr :color, :string, default: "info" + attr :class, :string, default: nil def ping_icon(assigns) do ~H""" - + +