fix(portal): Fix IdP syncs (#3816)

This commit is contained in:
Andrew Dryga
2024-02-29 15:19:53 -06:00
committed by GitHub
parent c5aab6ebba
commit 3c04025be1
25 changed files with 477 additions and 185 deletions

View File

@@ -17,9 +17,15 @@ defmodule Domain.Actors.Membership.Sync do
memberships: memberships
} ->
tuples =
Enum.map(tuples, fn {group_provider_identifier, actor_provider_identifier} ->
{Map.fetch!(group_ids_by_provider_identifier, group_provider_identifier),
Map.fetch!(actor_ids_by_provider_identifier, actor_provider_identifier)}
Enum.flat_map(tuples, fn {group_provider_identifier, actor_provider_identifier} ->
group_id = Map.get(group_ids_by_provider_identifier, group_provider_identifier)
actor_id = Map.get(actor_ids_by_provider_identifier, actor_provider_identifier)
if is_nil(group_id) or is_nil(actor_id) do
[]
else
[{group_id, actor_id}]
end
end)
plan_memberships_update(tuples, memberships)

View File

@@ -1,8 +1,14 @@
defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
@moduledoc """
Warning: DO NOT use `fields` parameter with Google API's,
or they will not return you pagination cursor 🫠.
"""
use Supervisor
@pool_name __MODULE__.Finch
@max_results 350
def start_link(_init_arg) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
@@ -40,19 +46,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
"customer" => "my_customer",
"showDeleted" => false,
"query" => "isSuspended=false isArchived=false",
"fields" =>
Enum.join(
~w[
users/id
users/primaryEmail
users/name/fullName
users/orgUnitPath
users/creationTime
users/isEnforcedIn2Sv
users/isEnrolledIn2Sv
],
","
)
"maxResults" => @max_results
})
)
@@ -68,7 +62,8 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
URI.parse("#{endpoint}/admin/directory/v1/groups")
|> URI.append_query(
URI.encode_query(%{
"customer" => "my_customer"
"customer" => "my_customer",
"maxResults" => @max_results
})
)
@@ -81,7 +76,14 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:endpoint)
uri = URI.parse("#{endpoint}/admin/directory/v1/customer/my_customer/orgunits")
uri =
URI.parse("#{endpoint}/admin/directory/v1/customer/my_customer/orgunits")
|> URI.append_query(
URI.encode_query(%{
"maxResults" => @max_results
})
)
list_all(uri, api_token, "organizationUnits")
end
@@ -90,9 +92,14 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClient do
Domain.Config.fetch_env!(:domain, __MODULE__)
|> Keyword.fetch!(:endpoint)
params = %{"includeDerivedMembership" => true}
uri = URI.parse("#{endpoint}/admin/directory/v1/groups/#{group_id}/members")
uri = URI.append_query(uri, URI.encode_query(params))
uri =
URI.parse("#{endpoint}/admin/directory/v1/groups/#{group_id}/members")
|> URI.append_query(
URI.encode_query(%{
"includeDerivedMembership" => true,
"maxResults" => @max_results
})
)
with {:ok, members} <- list_all(uri, api_token, "members") do
members =

View File

@@ -42,119 +42,142 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
|> Domain.Repo.preload(:account)
|> Enum.chunk_every(5)
|> Enum.each(fn providers ->
Enum.map(providers, fn provider ->
Logger.debug("Syncing provider", provider_id: provider.id)
access_token = provider.adapter_state["access_token"]
with true <- Domain.Accounts.idp_sync_enabled?(provider.account),
{:ok, users} <- GoogleWorkspace.APIClient.list_users(access_token),
{:ok, organization_units} <-
GoogleWorkspace.APIClient.list_organization_units(access_token),
{:ok, groups} <- GoogleWorkspace.APIClient.list_groups(access_token),
{:ok, tuples} <-
list_membership_tuples(access_token, groups) do
identities_attrs =
Enum.map(users, fn user ->
%{
"provider_identifier" => user["id"],
"provider_state" => %{
"userinfo" => %{
"email" => user["primaryEmail"]
}
},
"actor" => %{
"type" => :account_user,
"name" => user["name"]["fullName"]
}
}
end)
actor_groups_attrs =
Enum.map(groups, fn group ->
%{
"name" => "Group:" <> group["name"],
"provider_identifier" => "G:" <> group["id"]
}
end) ++
Enum.map(organization_units, fn organization_unit ->
%{
"name" => "OrgUnit:" <> organization_unit["name"],
"provider_identifier" => "OU:" <> organization_unit["orgUnitId"]
}
end)
tuples =
Enum.flat_map(users, fn user ->
organization_unit =
Enum.find(organization_units, fn organization_unit ->
organization_unit["orgUnitPath"] == user["orgUnitPath"]
end)
if organization_unit["orgUnitId"] do
[{"OU:" <> organization_unit["orgUnitId"], user["id"]}]
else
[]
end
end) ++ tuples
Ecto.Multi.new()
|> Ecto.Multi.append(Auth.sync_provider_identities_multi(provider, identities_attrs))
|> 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 ->
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction()
|> case do
{:ok, effects} ->
SyncLogger.log_effects(provider, effects)
{:error, reason} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
reason: inspect(reason)
)
end
else
false ->
Auth.Provider.Changeset.sync_failed(
provider,
"IdP sync is not enabled in your subscription plan"
)
|> Domain.Repo.update!()
:ok
{: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)
)
end
end)
Enum.map(providers, &sync_provider/1)
end)
end
end
def sync_provider(provider) do
Logger.debug("Syncing provider",
account_id: provider.account_id,
provider_id: provider.id
)
access_token = provider.adapter_state["access_token"]
with true <- Domain.Accounts.idp_sync_enabled?(provider.account),
{:ok, users} <- GoogleWorkspace.APIClient.list_users(access_token),
{:ok, organization_units} <-
GoogleWorkspace.APIClient.list_organization_units(access_token),
{:ok, groups} <- GoogleWorkspace.APIClient.list_groups(access_token),
{:ok, tuples} <-
list_membership_tuples(access_token, groups) do
identities_attrs =
Enum.map(users, fn user ->
%{
"provider_identifier" => user["id"],
"provider_state" => %{
"userinfo" => %{
"email" => user["primaryEmail"]
}
},
"actor" => %{
"type" => :account_user,
"name" => user["name"]["fullName"]
}
}
end)
actor_groups_attrs =
Enum.map(groups, fn group ->
%{
"name" => "Group:" <> group["name"],
"provider_identifier" => "G:" <> group["id"]
}
end) ++
Enum.map(organization_units, fn organization_unit ->
%{
"name" => "OrgUnit:" <> organization_unit["name"],
"provider_identifier" => "OU:" <> organization_unit["orgUnitId"]
}
end)
tuples =
Enum.flat_map(users, fn user ->
organization_unit =
Enum.find(organization_units, fn organization_unit ->
organization_unit["orgUnitPath"] == user["orgUnitPath"]
end)
if organization_unit["orgUnitId"] do
[{"OU:" <> organization_unit["orgUnitId"], user["id"]}]
else
[]
end
end) ++ tuples
Ecto.Multi.new()
|> Ecto.Multi.append(Auth.sync_provider_identities_multi(provider, identities_attrs))
|> 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 ->
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction(timeout: :timer.minutes(15))
|> case do
{:ok, effects} ->
SyncLogger.log_effects(provider, effects)
{:error, reason} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
reason: inspect(reason)
)
{:error, reason}
{:error, step, reason, _effects_so_far} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
step: inspect(step),
reason: inspect(reason)
)
{:error, reason}
end
else
false ->
Auth.Provider.Changeset.sync_failed(
provider,
"IdP sync is not enabled in your subscription plan"
)
|> Domain.Repo.update!()
:ok
{:error, {401, %{"error" => %{"message" => message}}}} ->
Auth.Provider.Changeset.sync_requires_manual_intervention(provider, message)
|> Domain.Repo.update!()
:ok
{: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 to sync provider",
account_id: provider.account_id,
provider_id: provider.id,
reason: inspect(reason)
)
end
end
defp log_sync_error(provider, message) do
metadata = [
account_id: provider.account_id,
@@ -163,9 +186,9 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.Jobs do
]
if provider.last_syncs_failed >= 3 do
Logger.warning("Failed syncing provider", metadata)
Logger.warning("Failed to sync provider", metadata)
else
Logger.info("Failed syncing provider", metadata)
Logger.info("Failed to sync provider", metadata)
end
end

View File

@@ -68,7 +68,7 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do
|> Ecto.Multi.update(:save_last_updated_at, fn _effects_so_far ->
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction()
|> Domain.Repo.transaction(timeout: :timer.minutes(15))
|> case do
{:ok, effects} ->
SyncLogger.log_effects(provider, effects)
@@ -80,14 +80,15 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do
reason: inspect(reason)
)
{:error, op, value, changes_so_far} ->
{:error, step, reason, _effects_so_far} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
op: op,
value: inspect(value),
changes_so_far: inspect(changes_so_far)
step: inspect(step),
reason: inspect(reason)
)
{:error, reason}
end
else
false ->
@@ -116,7 +117,7 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do
log_sync_error(provider, message)
{:error, reason} ->
Logger.error("Failed syncing provider",
Logger.error("Failed to sync provider",
account_id: provider.account_id,
provider_id: provider.id,
reason: inspect(reason)
@@ -132,9 +133,9 @@ defmodule Domain.Auth.Adapters.MicrosoftEntra.Jobs do
]
if provider.last_syncs_failed >= 3 do
Logger.warning("Failed syncing provider", metadata)
Logger.warning("Failed to sync provider", metadata)
else
Logger.info("Failed syncing provider", metadata)
Logger.info("Failed to sync provider", metadata)
end
end

View File

@@ -78,7 +78,7 @@ defmodule Domain.Auth.Adapters.Okta.Jobs do
|> Ecto.Multi.update(:save_last_updated_at, fn _effects_so_far ->
Auth.Provider.Changeset.sync_finished(provider)
end)
|> Domain.Repo.transaction()
|> Domain.Repo.transaction(timeout: :timer.minutes(15))
|> case do
{:ok, effects} ->
SyncLogger.log_effects(provider, effects)
@@ -90,14 +90,15 @@ defmodule Domain.Auth.Adapters.Okta.Jobs do
reason: inspect(reason)
)
{:error, op, value, changes_so_far} ->
{:error, step, reason, _effects_so_far} ->
Logger.error("Failed to sync provider",
provider_id: provider.id,
account_id: provider.account_id,
op: op,
value: inspect(value),
changes_so_far: inspect(changes_so_far)
step: inspect(step),
reason: inspect(reason)
)
{:error, reason}
end
else
{:error, {status, %{"errorCode" => error_code, "errorSummary" => error_summary}}} ->
@@ -119,7 +120,7 @@ defmodule Domain.Auth.Adapters.Okta.Jobs do
log_sync_error(provider, message)
{:error, reason} ->
Logger.error("Failed syncing provider",
Logger.error("Failed to sync provider",
account_id: provider.account_id,
provider_id: provider.id,
reason: inspect(reason)
@@ -135,9 +136,9 @@ defmodule Domain.Auth.Adapters.Okta.Jobs do
]
if provider.last_syncs_failed >= 3 do
Logger.warning("Failed syncing provider", metadata)
Logger.warning("Failed to sync provider", metadata)
else
Logger.info("Failed syncing provider", metadata)
Logger.info("Failed to sync provider", metadata)
end
end

View File

@@ -25,7 +25,7 @@ defmodule Domain.Auth.Identity.Sync do
|> Ecto.Multi.run(
:insert_identities,
fn repo, %{plan_identities: {insert, _update, _delete}} ->
upsert_identities(repo, provider, attrs_by_provider_identifier, insert)
insert_identities(repo, provider, attrs_by_provider_identifier, insert)
end
)
|> Ecto.Multi.run(
@@ -89,6 +89,8 @@ defmodule Domain.Auth.Identity.Sync do
end
defp delete_identities(repo, provider, provider_identifiers_to_delete) do
provider_identifiers_to_delete = Enum.uniq(provider_identifiers_to_delete)
{_count, identities} =
Identity.Query.by_account_id(provider.account_id)
|> Identity.Query.by_provider_id(provider.id)
@@ -104,13 +106,14 @@ defmodule Domain.Auth.Identity.Sync do
{:ok, identities}
end
defp upsert_identities(
defp insert_identities(
repo,
provider,
attrs_by_provider_identifier,
provider_identifiers_to_insert
) do
provider_identifiers_to_insert
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn provider_identifier, {:ok, acc} ->
attrs = Map.get(attrs_by_provider_identifier, provider_identifier)
changeset = Identity.Changeset.create_identity_and_actor(provider, attrs)
@@ -136,10 +139,13 @@ defmodule Domain.Auth.Identity.Sync do
|> Enum.filter(fn identity ->
identity.provider_identifier in provider_identifiers_to_update
end)
# make sure that deleted identities are in the end in case of conflicts
|> Enum.sort_by(& &1.deleted_at, :desc)
|> repo.preload(:actor)
|> Map.new(&{&1.provider_identifier, &1})
provider_identifiers_to_update
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn provider_identifier, {:ok, acc} ->
identity = Map.get(identity_by_provider_identifier, provider_identifier)
attrs = Map.get(attrs_by_provider_identifier, provider_identifier)

View File

@@ -22,6 +22,7 @@ defmodule Domain.Auth.Provider do
field :last_syncs_failed, :integer
field :last_sync_error, :string
field :last_synced_at, :utc_datetime_usec
field :sync_disabled_at, :utc_datetime_usec
field :disabled_at, :utc_datetime_usec
field :deleted_at, :utc_datetime_usec

View File

@@ -4,7 +4,9 @@ defmodule Domain.Auth.Provider.Changeset do
alias Domain.Auth.{Subject, Provider, Adapters}
@create_fields ~w[id name adapter provisioner adapter_config adapter_state disabled_at]a
@update_fields ~w[name adapter_config last_syncs_failed last_sync_error adapter_state provisioner disabled_at deleted_at]a
@update_fields ~w[name adapter_config
last_syncs_failed last_sync_error sync_disabled_at
adapter_state provisioner disabled_at deleted_at]a
@required_fields ~w[name adapter adapter_config provisioner]a
def create(account, attrs, %Subject{} = subject) do
@@ -47,6 +49,7 @@ defmodule Domain.Auth.Provider.Changeset do
|> put_change(:last_synced_at, DateTime.utc_now())
|> put_change(:last_sync_error, nil)
|> put_change(:last_syncs_failed, 0)
|> put_change(:sync_disabled_at, nil)
end
def sync_failed(%Provider{} = provider, error) do
@@ -59,6 +62,11 @@ defmodule Domain.Auth.Provider.Changeset do
|> put_change(:last_syncs_failed, last_syncs_failed + 1)
end
def sync_requires_manual_intervention(%Provider{} = provider, error) do
sync_failed(provider, error)
|> put_change(:sync_disabled_at, DateTime.utc_now())
end
defp changeset(changeset) do
changeset
|> validate_length(:name, min: 1, max: 255)

View File

@@ -14,10 +14,6 @@ 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 by_id(queryable \\ not_deleted(), id)
def by_id(queryable, {:not, id}) do
@@ -47,8 +43,8 @@ defmodule Domain.Auth.Provider.Query do
end
def only_ready_to_be_synced(queryable \\ not_deleted()) do
where(
queryable,
queryable
|> where(
[provider: provider],
is_nil(provider.last_synced_at) or
fragment(
@@ -57,6 +53,7 @@ defmodule Domain.Auth.Provider.Query do
provider.last_syncs_failed
)
)
|> where([provider: provider], is_nil(provider.sync_disabled_at))
end
def by_non_empty_refresh_token(queryable \\ not_deleted()) do

View File

@@ -0,0 +1,9 @@
defmodule Domain.Repo.Migrations.AddAuthProvidersSyncDisabledAt do
use Ecto.Migration
def change do
alter table(:auth_providers) do
add(:sync_disabled_at, :utc_datetime_usec)
end
end
end

View File

@@ -957,6 +957,104 @@ defmodule Domain.ActorsTest do
insert_memberships: []
}} = Repo.transaction(multi)
end
test "deletes actors that are not processed by identity sync", %{
account: account,
provider: provider,
group1: group1,
group2: group2,
identity1: identity1,
identity2: identity2
} do
Fixtures.Actors.create_membership(
account: account,
group: group1,
actor_id: identity1.actor_id
)
Fixtures.Actors.create_membership(
account: account,
group: group2,
actor_id: identity2.actor_id
)
tuples_list = [
{group1.provider_identifier, identity1.provider_identifier},
{group2.provider_identifier, identity2.provider_identifier}
]
actor_ids_by_provider_identifier = %{}
group_ids_by_provider_identifier = %{
group1.provider_identifier => group1.id,
group2.provider_identifier => group2.id
}
multi =
Ecto.Multi.new()
|> Ecto.Multi.put(:actor_ids_by_provider_identifier, actor_ids_by_provider_identifier)
|> Ecto.Multi.put(:group_ids_by_provider_identifier, group_ids_by_provider_identifier)
|> sync_provider_memberships_multi(provider, tuples_list)
assert {:ok,
%{
plan_memberships: {[], delete},
delete_memberships: {2, nil},
insert_memberships: []
}} = Repo.transaction(multi)
assert {group1.id, identity1.actor_id} in delete
assert {group2.id, identity2.actor_id} in delete
end
test "deletes groups that are not processed by groups sync", %{
account: account,
provider: provider,
group1: group1,
group2: group2,
identity1: identity1,
identity2: identity2
} do
Fixtures.Actors.create_membership(
account: account,
group: group1,
actor_id: identity1.actor_id
)
Fixtures.Actors.create_membership(
account: account,
group: group2,
actor_id: identity2.actor_id
)
tuples_list = [
{group1.provider_identifier, identity1.provider_identifier},
{group2.provider_identifier, identity2.provider_identifier}
]
actor_ids_by_provider_identifier = %{
identity1.provider_identifier => identity1.actor_id,
identity2.provider_identifier => identity2.actor_id
}
group_ids_by_provider_identifier = %{}
multi =
Ecto.Multi.new()
|> Ecto.Multi.put(:actor_ids_by_provider_identifier, actor_ids_by_provider_identifier)
|> Ecto.Multi.put(:group_ids_by_provider_identifier, group_ids_by_provider_identifier)
|> sync_provider_memberships_multi(provider, tuples_list)
assert {:ok,
%{
plan_memberships: {[], delete},
delete_memberships: {2, nil},
insert_memberships: []
}} = Repo.transaction(multi)
assert {group1.id, identity1.actor_id} in delete
assert {group2.id, identity2.actor_id} in delete
end
end
describe "new_group/0" do

View File

@@ -32,21 +32,9 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClientTest do
assert conn.params == %{
"customer" => "my_customer",
"fields" =>
Enum.join(
~w[
users/id
users/primaryEmail
users/name/fullName
users/orgUnitPath
users/creationTime
users/isEnforcedIn2Sv
users/isEnrolledIn2Sv
],
","
),
"query" => "isSuspended=false isArchived=false",
"showDeleted" => "false"
"showDeleted" => "false",
"maxResults" => "350"
}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
@@ -77,7 +65,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClientTest do
end
assert_receive {:bypass_request, conn}
assert conn.params == %{}
assert conn.params == %{"maxResults" => "350"}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
end
@@ -109,7 +97,8 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClientTest do
assert_receive {:bypass_request, conn}
assert conn.params == %{
"customer" => "my_customer"
"customer" => "my_customer",
"maxResults" => "350"
}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
@@ -141,7 +130,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.APIClientTest do
end
assert_receive {:bypass_request, conn}
assert conn.params == %{"includeDerivedMembership" => "true"}
assert conn.params == %{"includeDerivedMembership" => "true", "maxResults" => "350"}
assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer #{api_token}"]
end

View File

@@ -704,6 +704,7 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.JobsTest do
refute updated_provider.last_synced_at
assert updated_provider.last_syncs_failed == 1
assert updated_provider.last_sync_error == error_message
refute updated_provider.sync_disabled_at
Bypass.expect_once(bypass, "GET", "/admin/directory/v1/users", fn conn ->
Plug.Conn.send_resp(conn, 500, "")
@@ -716,5 +717,64 @@ defmodule Domain.Auth.Adapters.GoogleWorkspace.JobsTest do
assert updated_provider.last_syncs_failed == 2
assert updated_provider.last_sync_error == "Google API is temporarily unavailable"
end
test "disables the sync on 401 response code", %{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" => 401,
"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, 401, 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
assert updated_provider.sync_disabled_at
end
end
end

View File

@@ -10,20 +10,20 @@ defmodule Domain.AuthTest do
test "returns list of enabled adapters for an account" do
account = Fixtures.Accounts.create_account(features: %{idp_sync: true})
assert list_user_provisioned_provider_adapters!(account) == [
openid_connect: [enabled: true],
assert Enum.sort(list_user_provisioned_provider_adapters!(account)) == [
google_workspace: [enabled: true],
microsoft_entra: [enabled: true],
okta: [enabled: true]
okta: [enabled: true],
openid_connect: [enabled: true]
]
account = Fixtures.Accounts.create_account(features: %{idp_sync: false})
assert list_user_provisioned_provider_adapters!(account) == [
openid_connect: [enabled: true],
assert Enum.sort(list_user_provisioned_provider_adapters!(account)) == [
google_workspace: [enabled: false],
microsoft_entra: [enabled: false],
okta: [enabled: false]
okta: [enabled: false],
openid_connect: [enabled: true]
]
end
end
@@ -482,6 +482,19 @@ defmodule Domain.AuthTest do
Domain.Fixture.update!(provider, %{last_synced_at: four_hours_one_minute_ago})
assert {:ok, [_provider]} = list_providers_pending_sync_by_adapter(:google_workspace)
end
test "ignores providers with disabled sync" do
{provider, _bypass} = Fixtures.Auth.start_and_create_google_workspace_provider()
eleven_minutes_ago = DateTime.utc_now() |> DateTime.add(-11, :minute)
Domain.Fixture.update!(provider, %{
last_synced_at: eleven_minutes_ago,
sync_disabled_at: DateTime.utc_now()
})
assert list_providers_pending_sync_by_adapter(:google_workspace) == {:ok, []}
end
end
describe "new_provider/2" do
@@ -1806,6 +1819,65 @@ defmodule Domain.AuthTest do
assert Repo.aggregate(Auth.Identity, :count) == 0
assert Repo.aggregate(Domain.Actors.Actor, :count) == 0
end
test "resolves provider identifier conflicts across actors", %{
account: account,
provider: provider
} do
identity1 =
Fixtures.Auth.create_identity(
account: account,
provider: provider,
provider_identifier: "USER_ID1",
actor: [type: :account_admin_user]
)
|> Fixtures.Auth.delete_identity()
identity2 =
Fixtures.Auth.create_identity(
account: account,
provider: provider,
provider_identifier: "USER_ID1",
actor: [type: :account_admin_user]
)
attrs_list = [
%{
"actor" => %{
"name" => "Brian Manifold",
"type" => "account_user"
},
"provider_identifier" => "USER_ID1"
}
]
multi = sync_provider_identities_multi(provider, attrs_list)
assert {:ok,
%{
identities: [_identity1, _identity2],
plan_identities: {[], update, []},
delete_identities: [],
insert_identities: [],
actor_ids_by_provider_identifier: actor_ids_by_provider_identifier
}} = Repo.transaction(multi)
assert length(update) == 2
assert update == ["USER_ID1", "USER_ID1"]
identity1 = Repo.get(Domain.Auth.Identity, identity1.id) |> Repo.preload(:actor)
assert identity1.deleted_at
assert identity1.actor.name != "Brian Manifold"
identity2 = Repo.get(Domain.Auth.Identity, identity2.id) |> Repo.preload(:actor)
refute identity2.deleted_at
assert identity2.actor.name == "Brian Manifold"
assert Map.get(actor_ids_by_provider_identifier, identity2.provider_identifier) ==
identity2.actor.id
assert Enum.count(actor_ids_by_provider_identifier) == 1
end
end
describe "upsert_identity/3" do

View File

@@ -280,7 +280,11 @@ defmodule Domain.Fixtures.Auth do
end
def fail_provider_sync(provider) do
update!(provider, last_sync_error: "Message from fixture", last_syncs_failed: 3)
update!(provider,
last_sync_error: "Message from fixture",
last_syncs_failed: 3,
sync_disabled_at: DateTime.utc_now()
)
end
def finish_provider_sync(provider) do

View File

@@ -260,7 +260,8 @@ defmodule Web.Settings.IdentityProviders.Components do
<div :if={not is_nil(@provider.last_synced_at)} class="flex items-center">
<span class={[
"w-3 h-3 rounded-full",
(@provider.last_syncs_failed > 3 && "bg-red-500") || "bg-green-500"
@provider.last_syncs_failed > 3 or (not is_nil(@provider.sync_disabled_at) && "bg-red-500") ||
"bg-green-500"
]}>
</span>
<span class="ml-3">

View File

@@ -49,7 +49,8 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Connect do
adapter_state: identity.provider_state,
disabled_at: nil,
last_syncs_failed: 0,
last_sync_error: nil
last_sync_error: nil,
sync_disabled_at: nil
},
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
redirect(conn,

View File

@@ -115,6 +115,7 @@ defmodule Web.Settings.IdentityProviders.GoogleWorkspace.Show do
<div
:if={
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
not is_nil(@provider.sync_disabled_at) or
(@provider.last_syncs_failed > 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"

View File

@@ -49,7 +49,8 @@ defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Connect do
adapter_state: identity.provider_state,
disabled_at: nil,
last_syncs_failed: 0,
last_sync_error: nil
last_sync_error: nil,
sync_disabled_at: nil
},
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
redirect(conn,

View File

@@ -115,6 +115,7 @@ defmodule Web.Settings.IdentityProviders.MicrosoftEntra.Show do
<div
:if={
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
not is_nil(@provider.sync_disabled_at) or
(@provider.last_syncs_failed > 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"

View File

@@ -49,7 +49,8 @@ defmodule Web.Settings.IdentityProviders.Okta.Connect do
adapter_state: identity.provider_state,
disabled_at: nil,
last_syncs_failed: 0,
last_sync_error: nil
last_sync_error: nil,
sync_disabled_at: nil
},
{:ok, _provider} <- Domain.Auth.update_provider(provider, attrs, subject) do
redirect(conn,

View File

@@ -113,6 +113,7 @@ defmodule Web.Settings.IdentityProviders.Okta.Show do
<div
:if={
(is_nil(@provider.last_synced_at) and not is_nil(@provider.last_sync_error)) or
not is_nil(@provider.sync_disabled_at) or
(@provider.last_syncs_failed > 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"

View File

@@ -207,6 +207,7 @@ defmodule Web.Live.Settings.IdentityProviders.GoogleWorkspace.Connect do
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
assert provider.last_sync_error == nil
assert provider.last_syncs_failed == 0
assert provider.sync_disabled_at == nil
end
test "redirects to the actors index when credentials are valid and return path is empty", %{

View File

@@ -218,6 +218,7 @@ defmodule Web.Live.Settings.IdentityProviders.MicrosoftEntra.Connect do
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
assert provider.last_sync_error == nil
assert provider.last_syncs_failed == 0
assert provider.sync_disabled_at == nil
end
test "redirects to the actors index when credentials are valid and return path is empty", %{

View File

@@ -210,6 +210,7 @@ defmodule Web.Live.Settings.IdentityProviders.Okta.Connect do
assert provider = Repo.get(Domain.Auth.Provider, provider.id)
assert provider.last_sync_error == nil
assert provider.last_syncs_failed == 0
assert provider.sync_disabled_at == nil
end
test "redirects to the actors index when credentials are valid and return path is empty", %{