From ded5feee9e0fee36a570fcf58210ead471b9ad1f Mon Sep 17 00:00:00 2001 From: Andrew Dryga Date: Mon, 18 Dec 2023 16:15:49 -0600 Subject: [PATCH] Fix user-reported errors (#2954) --- elixir/apps/domain/lib/domain/auth.ex | 4 +- .../adapters/google_workspace/api_client.ex | 21 +- .../auth/adapters/google_workspace/jobs.ex | 33 +++- .../apps/domain/lib/domain/auth/provider.ex | 3 + .../lib/domain/auth/provider/changeset.ex | 20 +- .../domain/lib/domain/auth/provider/query.ex | 17 ++ elixir/apps/domain/lib/domain/clients.ex | 24 ++- .../cluster/google_compute_labels_strategy.ex | 12 +- ...356_add_auth_providers_last_sync_error.exs | 10 + .../adapters/google_workspace/jobs_test.exs | 70 +++++++ elixir/apps/domain/test/domain/auth_test.exs | 25 +++ .../apps/domain/test/domain/clients_test.exs | 22 +++ .../apps/domain/test/support/fixtures/auth.ex | 12 ++ elixir/apps/web/assets/js/hooks.js | 35 ---- elixir/apps/web/assets/vendor/status_page.js | 184 ------------------ .../web/lib/web/components/core_components.ex | 10 - .../lib/web/components/layouts/app.html.heex | 8 +- elixir/apps/web/lib/web/live/actors/show.ex | 16 +- elixir/apps/web/lib/web/live/clients/index.ex | 46 +---- elixir/apps/web/lib/web/live/clients/show.ex | 27 +++ elixir/apps/web/lib/web/live/gateways/show.ex | 24 ++- .../web/lib/web/live/relay_groups/show.ex | 11 +- elixir/apps/web/lib/web/live/relays/show.ex | 22 ++- .../settings/identity_providers/components.ex | 6 +- .../google_workspace/connect.ex | 4 +- .../google_workspace/show.ex | 42 +++- .../web/test/web/live/actors/show_test.exs | 38 ++++ .../web/test/web/live/clients/show_test.exs | 1 + .../google_workspace/connect_test.exs | 53 +++++ .../google_workspace/show_test.exs | 38 ++++ 30 files changed, 521 insertions(+), 317 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20231218175356_add_auth_providers_last_sync_error.exs delete mode 100644 elixir/apps/web/assets/vendor/status_page.js diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index 5227419fa..46c66efc6 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -142,11 +142,9 @@ defmodule Domain.Auth do end def list_providers_pending_sync_by_adapter(adapter) do - datetime_filter = DateTime.utc_now() |> DateTime.add(-10, :minute) - Provider.Query.by_adapter(adapter) |> Provider.Query.by_provisioner(:custom) - |> Provider.Query.last_synced_at({:lt, datetime_filter}) + |> Provider.Query.only_ready_to_be_synced() |> Provider.Query.not_disabled() |> Repo.list() end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex index 23ef3ef68..aa5bd0350 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/api_client.ex @@ -126,10 +126,23 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do {:ok, list} <- Map.fetch(json_response, key) do {:ok, list, json_response["nextPageToken"]} else - {:ok, %Finch.Response{status: status}} when status in 500..599 -> {:error, :retry_later} - {:ok, %Finch.Response{body: response, status: status}} -> {:error, {status, response}} - :error -> {:ok, [], nil} - other -> other + {:ok, %Finch.Response{status: status}} when status in 500..599 -> + {:error, :retry_later} + + {:ok, %Finch.Response{body: response, status: status}} -> + case Jason.decode(response) do + {:ok, json_response} -> + {:error, {status, json_response}} + + _error -> + {:error, {status, response}} + end + + :error -> + {:ok, [], nil} + + other -> + other end end end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex index 222ec3803..8397c853e 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters/google_workspace/jobs.ex @@ -100,7 +100,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do |> Ecto.Multi.append(Actors.sync_provider_groups_multi(provider, actor_groups_attrs)) |> Actors.sync_provider_memberships_multi(provider, tuples) |> Ecto.Multi.update(:save_last_updated_at, fn _effects_so_far -> - Ecto.Changeset.change(provider, last_synced_at: DateTime.utc_now()) + Auth.Provider.Changeset.sync_finished(provider) end) |> Domain.Repo.transaction() |> case do @@ -152,8 +152,25 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do ) end else + {:error, {status, %{"error" => %{"message" => message}}}} -> + provider = + Auth.Provider.Changeset.sync_failed(provider, message) + |> Domain.Repo.update!() + + log_sync_error(provider, "Google API returned #{status}: #{message}") + + {:error, :retry_later} -> + message = "Google API is temporarily unavailable" + + provider = + Auth.Provider.Changeset.sync_failed(provider, message) + |> Domain.Repo.update!() + + log_sync_error(provider, message) + {:error, reason} -> Logger.error("Failed syncing provider", + account_id: provider.account_id, provider_id: provider.id, reason: inspect(reason) ) @@ -163,6 +180,20 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do end end + defp log_sync_error(provider, message) do + metadata = [ + account_id: provider.account_id, + provider_id: provider.id, + reason: message + ] + + if provider.last_syncs_failed >= 3 do + Logger.warning("Failed syncing provider", metadata) + else + Logger.info("Failed syncing provider", metadata) + end + end + defp list_membership_tuples(access_token, groups) do Enum.reduce_while(groups, {:ok, []}, fn group, {:ok, tuples} -> case GoogleWorkspace.APIClient.list_group_members(access_token, group["id"]) do diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index d19452a3c..a752730a7 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -17,7 +17,10 @@ defmodule Domain.Auth.Provider do field :created_by, Ecto.Enum, values: ~w[system identity]a belongs_to :created_by_identity, Domain.Auth.Identity + field :last_syncs_failed, :integer + field :last_sync_error, :string field :last_synced_at, :utc_datetime_usec + field :disabled_at, :utc_datetime_usec field :deleted_at, :utc_datetime_usec timestamps() diff --git a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex index 6fa31d83c..ae1ade9af 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/changeset.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/changeset.ex @@ -4,7 +4,7 @@ defmodule Domain.Auth.Provider.Changeset do alias Domain.Auth.{Subject, Provider} @create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a - @update_fields ~w[name adapter_config adapter_state provisioner disabled_at deleted_at]a + @update_fields ~w[name adapter_config last_syncs_failed last_sync_error adapter_state provisioner disabled_at deleted_at]a @required_fields ~w[name adapter adapter_config provisioner]a def create(account, attrs, %Subject{} = subject) do @@ -28,6 +28,24 @@ defmodule Domain.Auth.Provider.Changeset do |> changeset() end + def sync_finished(%Provider{} = provider) do + provider + |> change() + |> put_change(:last_synced_at, DateTime.utc_now()) + |> put_change(:last_sync_error, nil) + |> put_change(:last_syncs_failed, 0) + end + + def sync_failed(%Provider{} = provider, error) do + last_syncs_failed = provider.last_syncs_failed || 0 + + provider + |> change() + |> put_change(:last_synced_at, nil) + |> put_change(:last_sync_error, error) + |> put_change(:last_syncs_failed, last_syncs_failed + 1) + end + defp changeset(changeset) do changeset |> validate_length(:name, min: 1, max: 255) diff --git a/elixir/apps/domain/lib/domain/auth/provider/query.ex b/elixir/apps/domain/lib/domain/auth/provider/query.ex index f1735a01e..99b26faa3 100644 --- a/elixir/apps/domain/lib/domain/auth/provider/query.ex +++ b/elixir/apps/domain/lib/domain/auth/provider/query.ex @@ -38,6 +38,19 @@ defmodule Domain.Auth.Provider.Query do ) end + def only_ready_to_be_synced(queryable \\ not_deleted()) do + where( + queryable, + [provider: provider], + is_nil(provider.last_synced_at) or + fragment( + "? + LEAST((interval '10 minute' * (COALESCE(?, 0) ^ 2 + 1)), interval '4 hours') < NOW()", + provider.last_synced_at, + provider.last_syncs_failed + ) + ) + end + def by_non_empty_refresh_token(queryable \\ not_deleted()) do where( queryable, @@ -66,6 +79,10 @@ defmodule Domain.Auth.Provider.Query do where(queryable, [provider: provider], is_nil(provider.disabled_at)) end + def not_exceeded_attempts(queryable \\ not_deleted()) do + where(queryable, [provider: provider], provider.last_syncs_failed <= 10) + end + def lock(queryable \\ not_deleted()) do lock(queryable, "FOR UPDATE") end diff --git a/elixir/apps/domain/lib/domain/clients.ex b/elixir/apps/domain/lib/domain/clients.ex index cf145f1bf..ec035eb70 100644 --- a/elixir/apps/domain/lib/domain/clients.ex +++ b/elixir/apps/domain/lib/domain/clients.ex @@ -1,7 +1,7 @@ defmodule Domain.Clients do use Supervisor alias Domain.{Repo, Auth, Validator} - alias Domain.Actors + alias Domain.{Accounts, Actors} alias Domain.Clients.{Client, Authorizer, Presence} require Ecto.Query @@ -127,13 +127,15 @@ defmodule Domain.Clients do # TODO: this is ugly! defp preload_online_status(client) do - connected_clients = Presence.list("clients:#{client.id}") - %{client | online?: Map.has_key?(connected_clients, client.id)} + case Presence.get_by_key("clients:#{client.account_id}", client.id) do + [] -> %{client | online?: false} + %{metas: [_ | _]} -> %{client | online?: true} + end end - defp preload_online_statuses([]), do: [] + def preload_online_statuses([]), do: [] - defp preload_online_statuses([client | _] = clients) do + def preload_online_statuses([client | _] = clients) do connected_clients = Presence.list("clients:#{client.account_id}") Enum.map(clients, fn client -> @@ -233,6 +235,18 @@ defmodule Domain.Clients do :ok end + def subscribe_for_clients_presence_in_account(%Accounts.Account{} = account) do + subscribe_for_clients_presence_in_account(account.id) + end + + def subscribe_for_clients_presence_in_account(account_id) do + Phoenix.PubSub.subscribe(Domain.PubSub, "clients:#{account_id}") + end + + def subscribe_for_clients_presence_for_actor(%Actors.Actor{} = actor) do + Phoenix.PubSub.subscribe(Domain.PubSub, "actor_clients:#{actor.id}") + end + def fetch_client_config!(%Client{} = client) do %{ clients_upstream_dns: upstream_dns diff --git a/elixir/apps/domain/lib/domain/cluster/google_compute_labels_strategy.ex b/elixir/apps/domain/lib/domain/cluster/google_compute_labels_strategy.ex index 27ce94840..9a4bfef55 100644 --- a/elixir/apps/domain/lib/domain/cluster/google_compute_labels_strategy.ex +++ b/elixir/apps/domain/lib/domain/cluster/google_compute_labels_strategy.ex @@ -123,13 +123,17 @@ defmodule Domain.Cluster.GoogleComputeLabelsStrategy do end {:error, reason} -> - Logger.error("Can not fetch list of nodes or access token: #{inspect(reason)}", - module: __MODULE__ - ) - if remaining_retry_count == 0 do + Logger.error("Can not fetch list of nodes or access token: #{inspect(reason)}", + module: __MODULE__ + ) + {:error, reason} else + Logger.warning("Can not fetch list of nodes or access token: #{inspect(reason)}", + module: __MODULE__ + ) + backoff_interval = Keyword.get(state.config, :backoff_interval, 1_000) :timer.sleep(backoff_interval) fetch_nodes(state, remaining_retry_count - 1) diff --git a/elixir/apps/domain/priv/repo/migrations/20231218175356_add_auth_providers_last_sync_error.exs b/elixir/apps/domain/priv/repo/migrations/20231218175356_add_auth_providers_last_sync_error.exs new file mode 100644 index 000000000..5e545b0e3 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20231218175356_add_auth_providers_last_sync_error.exs @@ -0,0 +1,10 @@ +defmodule Domain.Repo.Migrations.AddAuthProvidersLastSyncError do + use Ecto.Migration + + def change do + alter table(:auth_providers) do + add(:last_syncs_failed, :integer, default: 0) + add(:last_sync_error, :text) + end + end +end diff --git a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs index d552ea433..073f61546 100644 --- a/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs +++ b/elixir/apps/domain/test/domain/auth/adapters/google_workspace/jobs_test.exs @@ -466,5 +466,75 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.JobsTest do assert org_unit.id in membership_group_ids assert deleted_group.id not in membership_group_ids end + + test "persists the sync error on the provider", %{provider: provider} do + bypass = Bypass.open() + GoogleWorkspaceDirectory.override_endpoint_url("http://localhost:#{bypass.port}/") + + error_message = + "Admin SDK API has not been used in project XXXX before or it is disabled. " <> + "Enable it by visiting https://console.developers.google.com/apis/api/admin.googleapis.com/overview?project=XXXX " <> + "then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + + response = + %{ + "error" => %{ + "code" => 403, + "message" => error_message, + "errors" => [ + %{ + "message" => error_message, + "domain" => "usageLimits", + "reason" => "accessNotConfigured", + "extendedHelp" => "https://console.developers.google.com" + } + ], + "status" => "PERMISSION_DENIED", + "details" => [ + %{ + "@type" => "type.googleapis.com/google.rpc.Help", + "links" => [ + %{ + "description" => "Google developers console API activation", + "url" => + "https://console.developers.google.com/apis/api/admin.googleapis.com/overview?project=100421656358" + } + ] + }, + %{ + "@type" => "type.googleapis.com/google.rpc.ErrorInfo", + "reason" => "SERVICE_DISABLED", + "domain" => "googleapis.com", + "metadata" => %{ + "service" => "admin.googleapis.com", + "consumer" => "projects/100421656358" + } + } + ] + } + } + + Bypass.expect_once(bypass, "GET", "/admin/directory/v1/users", fn conn -> + Plug.Conn.send_resp(conn, 403, Jason.encode!(response)) + end) + + assert sync_directory(%{}) == :ok + + assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id) + refute updated_provider.last_synced_at + assert updated_provider.last_syncs_failed == 1 + assert updated_provider.last_sync_error == error_message + + Bypass.expect_once(bypass, "GET", "/admin/directory/v1/users", fn conn -> + Plug.Conn.send_resp(conn, 500, "") + end) + + assert sync_directory(%{}) == :ok + + assert updated_provider = Repo.get(Domain.Auth.Provider, provider.id) + refute updated_provider.last_synced_at + assert updated_provider.last_syncs_failed == 2 + assert updated_provider.last_sync_error == "Google API is temporarily unavailable" + end end end diff --git a/elixir/apps/domain/test/domain/auth_test.exs b/elixir/apps/domain/test/domain/auth_test.exs index 0d334e60b..2c12e5940 100644 --- a/elixir/apps/domain/test/domain/auth_test.exs +++ b/elixir/apps/domain/test/domain/auth_test.exs @@ -362,6 +362,31 @@ defmodule Domain.AuthTest do assert Enum.map(providers, & &1.id) |> Enum.sort() == Enum.sort([provider1.id, provider2.id]) end + + test "uses 1/2 regular timeout backoff for failed attempts" do + {provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider() + # backoff: 10 minutes * (1 + 3 ^ 2) = 100 minutes + provider = Domain.Fixture.update!(provider, %{last_sync_error: "foo", last_syncs_failed: 3}) + + ninety_nine_minute_ago = DateTime.utc_now() |> DateTime.add(-99, :minute) + Domain.Fixture.update!(provider, %{last_synced_at: ninety_nine_minute_ago}) + assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []} + + one_hundred_one_minute_ago = DateTime.utc_now() |> DateTime.add(-101, :minute) + Domain.Fixture.update!(provider, %{last_synced_at: one_hundred_one_minute_ago}) + assert {:ok, [_provider]} = list_providers_pending_sync_by_adapter(:google_workspace) + + # max backoff: 4 hours + provider = Domain.Fixture.update!(provider, %{last_syncs_failed: 300}) + + three_hours_fifty_nine_minutes_ago = DateTime.utc_now() |> DateTime.add(-239, :minute) + Domain.Fixture.update!(provider, %{last_synced_at: three_hours_fifty_nine_minutes_ago}) + assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []} + + four_hours_one_minute_ago = DateTime.utc_now() |> DateTime.add(-241, :minute) + Domain.Fixture.update!(provider, %{last_synced_at: four_hours_one_minute_ago}) + assert {:ok, [_provider]} = list_providers_pending_sync_by_adapter(:google_workspace) + end end describe "new_provider/2" do diff --git a/elixir/apps/domain/test/domain/clients_test.exs b/elixir/apps/domain/test/domain/clients_test.exs index d329285d5..be94943d0 100644 --- a/elixir/apps/domain/test/domain/clients_test.exs +++ b/elixir/apps/domain/test/domain/clients_test.exs @@ -71,6 +71,17 @@ defmodule Domain.ClientsTest do assert fetch_client_by_id(client.id, subject) == {:ok, client} end + test "preloads online status", %{unprivileged_actor: actor, unprivileged_subject: subject} do + client = Fixtures.Clients.create_client(actor: actor) + + assert {:ok, client} = fetch_client_by_id(client.id, subject) + assert client.online? == false + + assert connect_client(client) == :ok + assert {:ok, client} = fetch_client_by_id(client.id, subject) + assert client.online? == true + end + test "returns client that belongs to another actor with manage permission", %{ account: account, unprivileged_subject: subject @@ -182,6 +193,17 @@ defmodule Domain.ClientsTest do assert length(clients) == 2 end + test "preloads online status", %{unprivileged_actor: actor, unprivileged_subject: subject} do + Fixtures.Clients.create_client(actor: actor) + + assert {:ok, [client]} = list_clients(subject) + assert client.online? == false + + assert connect_client(client) == :ok + assert {:ok, [client]} = list_clients(subject) + assert client.online? == true + end + test "returns error when subject has no permission to manage clients", %{ unprivileged_subject: subject } do diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 157ac65d0..a26caae66 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -198,6 +198,18 @@ defmodule Domain.Fixtures.Auth do update!(provider, deleted_at: DateTime.utc_now()) end + def fail_provider_sync(provider) do + update!(provider, last_sync_error: "Message from fixture", last_syncs_failed: 3) + end + + def finish_provider_sync(provider) do + update!(provider, + last_synced_at: DateTime.utc_now(), + last_sync_error: nil, + last_syncs_failed: 0 + ) + end + def identity_attrs(attrs \\ %{}) do Enum.into(attrs, %{ provider_virtual_state: %{} diff --git a/elixir/apps/web/assets/js/hooks.js b/elixir/apps/web/assets/js/hooks.js index 77fafc07b..6d10815d9 100644 --- a/elixir/apps/web/assets/js/hooks.js +++ b/elixir/apps/web/assets/js/hooks.js @@ -1,4 +1,3 @@ -import StatusPage from "../vendor/status_page"; import { initTabs } from "flowbite"; let Hooks = {}; @@ -62,38 +61,4 @@ Hooks.Copy = { }, }; -// Update status indicator when sidebar is mounted or updated -let statusIndicatorClassNames = { - none: "bg-green-100 text-green-800", - minor: "bg-yellow-100 text-yellow-800", - major: "bg-orange-100 text-orange-800", - critical: "bg-red-100 text-red-800", -}; - -const statusUpdater = function () { - const self = this; - const sp = new StatusPage.page({ page: "firezone" }); - - sp.summary({ - success: function (data) { - self.el.innerHTML = ` - - ${data.status.description} - - `; - }, - error: function (data) { - console.error("An error occurred while fetching status page data"); - self.el.innerHTML = `Unable to fetch status`; - }, - }); -}; - -Hooks.StatusPage = { - mounted: statusUpdater, - updated: statusUpdater, -}; - export default Hooks; diff --git a/elixir/apps/web/assets/vendor/status_page.js b/elixir/apps/web/assets/vendor/status_page.js deleted file mode 100644 index f32088a94..000000000 --- a/elixir/apps/web/assets/vendor/status_page.js +++ /dev/null @@ -1,184 +0,0 @@ -// StatusPage API Wrapper. Vendored and reviewed by @jamilbk from https://cdn.statuspage.io/se-v2.js -;(StatusPage = "undefined" == typeof StatusPage ? {} : StatusPage), - (StatusPage.page = function (e) { - if (!(e = e || {}).page) - throw new Error("A pageId is required to initialize.") - ;(this.apiKey = e.apiKey || null), - (this.error = e.error || this.error), - (this.format = e.format || "json"), - (this.pageId = e.page), - (this.version = e.version || "v2"), - (this.secure = !("secure" in e) || e.secure), - (this.protocol = this.secure ? "https" : "http"), - (this.host = e.host || "statuspage.io"), - (this.host_with_port_and_protocol = e.test - ? "" - : this.protocol + "://" + this.pageId + "." + this.host) - }), - (StatusPage.page.prototype.serialize = function (e, t) { - var s = [], - r = { sms: "email_sms", webhook: "endpoint" } - for (var o in e) - if ("to_sentence" !== o) { - var i = o - o = o in r ? r[o] : o - var a = t ? t + "[" + o + "]" : o, - n = e[i] - s.push( - "object" == typeof n - ? this.serialize(n, a) - : encodeURIComponent(a) + "=" + encodeURIComponent(n) - ) - } - return s.join("&") - }), - (StatusPage.page.prototype.createStatusPageCORSRequest = function (e, t) { - var s = new XMLHttpRequest() - return ( - "withCredentials" in s - ? s.open(e, t, !0) - : "undefined" != typeof XDomainRequest - ? (s = new XDomainRequest()).open(e, t) - : (s = null), - s - ) - }), - (StatusPage.page.prototype.executeRequestAndCallbackWithResponse = function ( - e - ) { - if (!e.path) throw new Error("A path is required to make a request") - var t = e.path, - s = e.method || "GET", - r = e.success || null, - o = e.error || this.error, - i = - this.host_with_port_and_protocol + - "/api/" + - this.version + - "/" + - t + - "." + - this.format, - a = this.createStatusPageCORSRequest(s, i) - if (a) - if ( - (this.apiKey && - (console.log( - "!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!" - ), - console.log( - "!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!" - ), - console.log( - "!!! API KEY IN USE - REMOVE BEFORE DEPLOYING TO PRODUCTION !!!" - ), - a.setRequestHeader("Authorization", "OAuth " + this.apiKey)), - (a.onload = function () { - var e = JSON.parse(a.responseText) - r && r(e) - }), - (a.onerror = o), - "POST" === s || "DELETE" === s) - ) { - var n = e.data || {} - a.setRequestHeader("Content-type", "application/x-www-form-urlencoded"), - a.send(this.serialize(n)) - } else a.send() - }), - (StatusPage.page.prototype.get = function (e, t) { - if (((t = t || {}), !e)) throw new Error("Path is required.") - if (!t.success) throw new Error("Success Callback is required.") - var s = t.success || {}, - r = t.error || {} - this.executeRequestAndCallbackWithResponse({ - path: e, - success: s, - error: r, - method: "GET", - }) - }), - (StatusPage.page.prototype.post = function (e, t) { - if (((t = t || {}), !e)) throw new Error("Path is required.") - var s = {} - if ("subscribers" === e) { - if (!t.subscriber) throw new Error("Subscriber is required to post.") - s.subscriber = t.subscriber - } else { - if (!t.data) throw new Error("Data is required to post.") - s = t.data - } - var r = t.success || {}, - o = t.error || {} - this.executeRequestAndCallbackWithResponse({ - data: s, - path: e, - success: r, - error: o, - method: "POST", - }) - }), - (StatusPage.page.prototype["delete"] = function (e, t) { - if (((t = t || {}), !e)) throw new Error("Path is required.") - if (!t.subscriber) throw new Error("Data is required to delete.") - var s = {} - "subscribers" === e ? (s.subscriber = t.subscriber) : (s = t.data) - var r = t.success || {}, - o = t.error || {} - this.executeRequestAndCallbackWithResponse({ - data: s, - path: e, - success: r, - error: o, - method: "DELETE", - }) - }), - (StatusPage.page.prototype.error = function () { - console.log("There was an error with your request") - }), - (StatusPage.page.prototype.summary = function (e) { - this.get("summary", e) - }), - (StatusPage.page.prototype.status = function (e) { - this.get("status", e) - }), - (StatusPage.page.prototype.components = function (e) { - this.get("components", e) - }), - (StatusPage.page.prototype.incidents = function (e) { - switch (e.filter) { - case "unresolved": - this.get("incidents/unresolved", e) - break - case "resolved": - this.get("incidents/resolved", e) - break - default: - this.get("incidents", e) - } - }), - (StatusPage.page.prototype.scheduled_maintenances = function (e) { - switch (e.filter) { - case "active": - this.get("scheduled-maintenances/active", e) - break - case "upcoming": - this.get("scheduled-maintenances/upcoming", e) - break - default: - this.get("scheduled-maintenances", e) - } - }), - (StatusPage.page.prototype.subscribe = function (e) { - if (!e || !e.subscriber) throw new Error("A subscriber object is required.") - this.post("subscribers", e) - }), - (StatusPage.page.prototype.unsubscribe = function (e) { - if (!e || !e.subscriber) throw new Error("A subscriber object is required.") - if (!e.subscriber.id) - throw new Error( - "You must supply a subscriber.id in order to cancel a subscription." - ) - this["delete"]("subscribers", e) - }) - -export default StatusPage diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index c1ffd12a3..b0eab33d1 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -673,16 +673,6 @@ defmodule Web.CoreComponents do """ end - def status_page_widget(assigns) do - ~H""" -
- <.link href="https://firezone.statuspage.io" class="text-xs hover:underline"> - - -
- """ - end - attr :type, :string, default: "neutral" attr :class, :string, default: nil attr :rest, :global diff --git a/elixir/apps/web/lib/web/components/layouts/app.html.heex b/elixir/apps/web/lib/web/components/layouts/app.html.heex index 3a5fc4160..d5270a48d 100644 --- a/elixir/apps/web/lib/web/components/layouts/app.html.heex +++ b/elixir/apps/web/lib/web/components/layouts/app.html.heex @@ -61,7 +61,13 @@ <:bottom> - <.status_page_widget /> +
+ <.link href="https://firezone.statuspage.io" class={["text-xs", link_style()]}> + + Platform status + + +
diff --git a/elixir/apps/web/lib/web/live/actors/show.ex b/elixir/apps/web/lib/web/live/actors/show.ex index 4ec297fef..3737b6b68 100644 --- a/elixir/apps/web/lib/web/live/actors/show.ex +++ b/elixir/apps/web/lib/web/live/actors/show.ex @@ -1,7 +1,7 @@ defmodule Web.Actors.Show do use Web, :live_view import Web.Actors.Components - alias Domain.{Auth, Flows} + alias Domain.{Auth, Flows, Clients} alias Domain.Actors def mount(%{"id" => id}, _session, socket) do @@ -17,6 +17,9 @@ defmodule Web.Actors.Show do Flows.list_flows_for(actor, socket.assigns.subject, preload: [gateway: [:group], client: [], policy: [:resource, :actor_group]] ) do + actor = %{actor | clients: Clients.preload_online_statuses(actor.clients)} + :ok = Clients.subscribe_for_clients_presence_for_actor(actor) + {:ok, assign(socket, actor: actor, @@ -263,6 +266,17 @@ defmodule Web.Actors.Show do """ end + def handle_info(%Phoenix.Socket.Broadcast{topic: "actor_clients:" <> _account_id}, socket) do + {:ok, actor} = + Actors.fetch_actor_by_id(socket.assigns.actor.id, socket.assigns.subject, + preload: [clients: []] + ) + + actor = %{socket.assigns.actor | clients: Clients.preload_online_statuses(actor.clients)} + + {:noreply, assign(socket, actor: actor)} + end + def handle_event("delete", _params, socket) do with {:ok, _actor} <- Actors.delete_actor(socket.assigns.actor, socket.assigns.subject) do {:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/actors")} diff --git a/elixir/apps/web/lib/web/live/clients/index.ex b/elixir/apps/web/lib/web/live/clients/index.ex index 37de25ec6..fd995c902 100644 --- a/elixir/apps/web/lib/web/live/clients/index.ex +++ b/elixir/apps/web/lib/web/live/clients/index.ex @@ -4,14 +4,13 @@ defmodule Web.Clients.Index do def mount(_params, _session, socket) do with {:ok, clients} <- Clients.list_clients(socket.assigns.subject, preload: :actor) do + :ok = Clients.subscribe_for_clients_presence_in_account(socket.assigns.subject.account) {:ok, assign(socket, clients: clients)} else {:error, _reason} -> raise Web.LiveErrors.NotFoundError end end - # subscribe for presence - def render(assigns) do ~H""" <.breadcrumbs account={@account}> @@ -58,43 +57,8 @@ defmodule Web.Clients.Index do """ end - # defp resource_filter(assigns) do - # ~H""" - #
- #
- #
- # - #
- #
- # <.icon name="hero-magnifying-glass" class="w-5 h-5 text-neutral-500" /> - #
- # - #
- #
- #
- # <.button_group> - # <:first> - # All - # - # <:middle> - # Online - # - # <:last> - # Archived - # - # - #
- # """ - # end + def handle_info(%Phoenix.Socket.Broadcast{topic: "clients:" <> _account_id}, socket) do + {:ok, clients} = Clients.list_clients(socket.assigns.subject, preload: :actor) + {:noreply, assign(socket, clients: clients)} + end end diff --git a/elixir/apps/web/lib/web/live/clients/show.ex b/elixir/apps/web/lib/web/live/clients/show.ex index c402783dd..4adcc2f8e 100644 --- a/elixir/apps/web/lib/web/live/clients/show.ex +++ b/elixir/apps/web/lib/web/live/clients/show.ex @@ -9,6 +9,8 @@ defmodule Web.Clients.Show do Flows.list_flows_for(client, socket.assigns.subject, preload: [gateway: [:group], policy: [:resource, :actor_group]] ) do + :ok = Clients.subscribe_for_clients_presence_in_account(client.account_id) + socket = assign( socket, @@ -52,6 +54,10 @@ defmodule Web.Clients.Show do <:label>Name <:value><%= @client.name %> + <.vertical_table_row> + <:label>Status + <:value><.connection_status schema={@client} /> + <.vertical_table_row> <:label>Owner <:value> @@ -152,6 +158,27 @@ defmodule Web.Clients.Show do """ end + def handle_info( + %Phoenix.Socket.Broadcast{topic: "clients:" <> _account_id, payload: payload}, + socket + ) do + client = socket.assigns.client + + socket = + cond do + Map.has_key?(payload.joins, client.id) -> + assign(socket, client: %{client | online?: true}) + + Map.has_key?(payload.leaves, client.id) -> + assign(socket, client: %{client | online?: false}) + + true -> + socket + end + + {:noreply, socket} + end + def handle_event("delete", _params, socket) do {:ok, _client} = Clients.delete_client(socket.assigns.client, socket.assigns.subject) {:noreply, push_navigate(socket, to: ~p"/#{socket.assigns.account}/clients")} diff --git a/elixir/apps/web/lib/web/live/gateways/show.ex b/elixir/apps/web/lib/web/live/gateways/show.ex index a6be1232e..b40e26295 100644 --- a/elixir/apps/web/lib/web/live/gateways/show.ex +++ b/elixir/apps/web/lib/web/live/gateways/show.ex @@ -121,17 +121,21 @@ defmodule Web.Gateways.Show do %Phoenix.Socket.Broadcast{topic: "gateway_groups:" <> _account_id, payload: payload}, socket ) do - if Map.has_key?(payload.joins, socket.assigns.gateway.id) or - Map.has_key?(payload.leaves, socket.assigns.gateway.id) do - {:ok, gateway} = - Gateways.fetch_gateway_by_id(socket.assigns.gateway.id, socket.assigns.subject, - preload: :group - ) + gateway = socket.assigns.gateway - {:noreply, assign(socket, gateway: gateway)} - else - {:noreply, socket} - end + socket = + cond do + Map.has_key?(payload.joins, gateway.id) -> + assign(socket, gateway: %{gateway | online?: true}) + + Map.has_key?(payload.leaves, gateway.id) -> + assign(socket, gateway: %{gateway | online?: false}) + + true -> + socket + end + + {:noreply, socket} end def handle_event("delete", _params, socket) do diff --git a/elixir/apps/web/lib/web/live/relay_groups/show.ex b/elixir/apps/web/lib/web/live/relay_groups/show.ex index bd6266307..9834e88a7 100644 --- a/elixir/apps/web/lib/web/live/relay_groups/show.ex +++ b/elixir/apps/web/lib/web/live/relay_groups/show.ex @@ -110,12 +110,15 @@ defmodule Web.RelayGroups.Show do end def handle_info(%Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id}, socket) do - socket = - push_navigate(socket, - to: ~p"/#{socket.assigns.account}/relay_groups/#{socket.assigns.group}" + {:ok, group} = + Relays.fetch_group_by_id(socket.assigns.group.id, socket.assigns.subject, + preload: [ + relays: [token: [created_by_identity: [:actor]]], + created_by_identity: [:actor] + ] ) - {:noreply, socket} + {:noreply, assign(socket, group: group)} end def handle_event("delete", _params, socket) do diff --git a/elixir/apps/web/lib/web/live/relays/show.ex b/elixir/apps/web/lib/web/live/relays/show.ex index d443e7b79..0cafd2a23 100644 --- a/elixir/apps/web/lib/web/live/relays/show.ex +++ b/elixir/apps/web/lib/web/live/relays/show.ex @@ -118,15 +118,21 @@ defmodule Web.Relays.Show do %Phoenix.Socket.Broadcast{topic: "relay_groups:" <> _account_id, payload: payload}, socket ) do - if Map.has_key?(payload.joins, socket.assigns.relay.id) or - Map.has_key?(payload.leaves, socket.assigns.relay.id) do - {:ok, relay} = - Relays.fetch_relay_by_id(socket.assigns.relay.id, socket.assigns.subject, preload: :group) + relay = socket.assigns.relay - {:noreply, assign(socket, relay: relay)} - else - {:noreply, socket} - end + socket = + cond do + Map.has_key?(payload.joins, relay.id) -> + assign(socket, relay: %{relay | online?: true}) + + Map.has_key?(payload.leaves, relay.id) -> + assign(socket, relay: %{relay | online?: false}) + + true -> + socket + end + + {:noreply, socket} end def handle_event("delete", _params, socket) do diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex index 3108a6c32..44ceb816c 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/components.ex @@ -148,7 +148,11 @@ defmodule Web.Settings.IdentityProviders.Components do def sync_status(%{provider: %{provisioner: :custom}} = assigns) do ~H"""
- + 3 && "bg-red-500") || "bg-green-500" + ]}> + Synced <.link navigate={~p"/#{@account}/actors?provider_id=#{@provider.id}"} class={link_style()}> diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex index 0509d890b..4297d0977 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/connect.ex @@ -47,7 +47,9 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do GoogleWorkspace.verify_and_upsert_identity(subject.actor, provider, payload), attrs = %{ adapter_state: identity.provider_state, - disabled_at: nil + disabled_at: nil, + last_syncs_failed: 0, + last_sync_error: nil }, {:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do redirect(conn, diff --git a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex index c671e87ce..963c5fe9f 100644 --- a/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex +++ b/elixir/apps/web/lib/web/live/settings/identity_providers/google_workspace/show.ex @@ -1,14 +1,23 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.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 - {:ok, assign(socket, provider: provider)} + ), + {:ok, identities_count_by_provider_id} <- + Auth.fetch_identities_count_grouped_by_provider_id(socket.assigns.subject), + {:ok, groups_count_by_provider_id} <- + Actors.fetch_groups_count_grouped_by_provider_id(socket.assigns.subject) do + {:ok, + assign(socket, + provider: provider, + identities_count_by_provider_id: identities_count_by_provider_id, + groups_count_by_provider_id: groups_count_by_provider_id + )} else _ -> raise Web.LiveErrors.NotFoundError end @@ -80,6 +89,33 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do <.status provider={@provider} /> + + <.vertical_table_row> + <:label>Sync Status + <:value> + <.sync_status + account={@account} + provider={@provider} + identities_count_by_provider_id={@identities_count_by_provider_id} + groups_count_by_provider_id={@groups_count_by_provider_id} + /> +
3 and not is_nil(@provider.last_sync_error)) + } + class="p-3 mt-2 border-l-4 border-red-500 bg-red-100 rounded-md" + > +

+ IdP provider reported an error during the last sync: +

+
+ <%= @provider.last_sync_error %> +
+
+ + + <.vertical_table_row> <:label>Client ID <:value><%= @provider.adapter_config["client_id"] %> 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 befe6df72..8919ecf18 100644 --- a/elixir/apps/web/test/web/live/actors/show_test.exs +++ b/elixir/apps/web/test/web/live/actors/show_test.exs @@ -52,6 +52,44 @@ defmodule Web.Live.Actors.ShowTest do assert breadcrumbs =~ actor.name end + test "renders clients table", %{ + conn: conn + } 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) + client = Fixtures.Clients.create_client(account: account, actor: actor) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/actors/#{actor}") + + [row] = + lv + |> element("#clients") + |> render() + |> table_to_map() + + assert row["name"] == client.name + assert row["status"] == "Offline" + + assert Domain.Clients.connect_client(client) == :ok + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/actors/#{actor}") + + [row] = + lv + |> element("#clients") + |> render() + |> table_to_map() + + assert row["status"] == "Online" + end + test "renders flows table", %{ conn: conn } do diff --git a/elixir/apps/web/test/web/live/clients/show_test.exs b/elixir/apps/web/test/web/live/clients/show_test.exs index d7805e880..8027f9b9c 100644 --- a/elixir/apps/web/test/web/live/clients/show_test.exs +++ b/elixir/apps/web/test/web/live/clients/show_test.exs @@ -91,6 +91,7 @@ defmodule Web.Live.Clients.ShowTest do assert table["identifier"] == client.id assert table["name"] == client.name assert table["owner"] =~ actor.name + assert table["status"] =~ "Offline" assert table["created"] assert table["last seen"] assert table["last seen remote ip"] =~ to_string(client.last_seen_remote_ip) diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs index d93d68dc5..c456be3aa 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/connect_test.exs @@ -149,6 +149,59 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.Connect do assert flash(conn, :error) == "Your session has expired, please try again." end + test "resets the sync error when IdP is reconnected", %{ + account: account, + conn: conn + } do + {provider, bypass} = + Fixtures.Auth.start_and_create_google_workspace_provider(account: account) + + provider = Fixtures.Auth.fail_provider_sync(provider) + + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor, provider: provider) + + redirected_conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> get( + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}/redirect", + %{} + ) + + {token, _claims} = Mocks.OpenIDConnect.generate_openid_connect_token(provider, identity) + Mocks.OpenIDConnect.expect_refresh_token(bypass, %{"id_token" => token}) + Mocks.OpenIDConnect.expect_userinfo(bypass) + + cookie_key = "fz_auth_state_#{provider.id}" + redirected_conn = fetch_cookies(redirected_conn) + {state, _verifier} = redirected_conn.cookies[cookie_key] |> :erlang.binary_to_term([:safe]) + %{value: signed_state} = redirected_conn.resp_cookies[cookie_key] + + conn = + conn + |> authorize_conn(identity) + |> assign(:account, account) + |> put_req_cookie(cookie_key, signed_state) + |> put_session(:foo, "bar") + |> put_session(:preferred_locale, "en_US") + |> get( + ~p"/#{account.id}/settings/identity_providers/google_workspace/#{provider.id}/handle_callback", + %{ + "state" => state, + "code" => "MyFakeCode" + } + ) + + assert redirected_to(conn) == + ~p"/#{account}/settings/identity_providers/google_workspace/#{provider}" + + assert provider = Repo.get(Domain.Auth.Provider, provider.id) + assert provider.last_sync_error == nil + assert provider.last_syncs_failed == 0 + end + test "redirects to the actors index when credentials are valid and return path is empty", %{ account: account, conn: conn diff --git a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs index 1aaac87d3..ac9e2df6a 100644 --- a/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs +++ b/elixir/apps/web/test/web/live/settings/identity_providers/google_workspace/show_test.exs @@ -91,10 +91,48 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.ShowTest do assert table["name"] == provider.name assert table["status"] == "Active" + assert table["sync status"] == "Never synced" assert table["client id"] == provider.adapter_config["client_id"] assert around_now?(table["created"]) end + test "renders sync status", %{ + account: account, + provider: provider, + identity: identity, + conn: conn + } do + provider = Fixtures.Auth.fail_provider_sync(provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ provider.last_sync_error + + provider = Fixtures.Auth.finish_provider_sync(provider) + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/settings/identity_providers/google_workspace/#{provider}") + + table = + lv + |> element("#provider") + |> render() + |> vertical_table_to_map() + + assert table["sync status"] =~ "Synced 1 identity and 0 groups" + end + test "renders name of actor that created provider", %{ account: account, actor: actor,