| defmodule Plausible.Auth.SSO do |
| @moduledoc """ |
| API for SSO. |
| """ |
|
|
| import Ecto.Changeset |
| import Ecto.Query |
|
|
| alias Plausible.Auth |
| alias Plausible.Auth.SSO |
| alias Plausible.Billing.Subscription |
| alias Plausible.Repo |
| alias Plausible.Teams |
|
|
| use Plausible.Auth.SSO.Domain.Status |
|
|
| require Plausible.Billing.Subscription.Status |
|
|
| @type policy_attr() :: |
| {:sso_default_role, Teams.Policy.sso_member_role()} |
| | {:sso_session_timeout_minutes, non_neg_integer()} |
|
|
| @spec get_integration_for(Teams.Team.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found} |
| def get_integration_for(%Teams.Team{} = team) do |
| query = integration_query() |> where([i], i.team_id == ^team.id) |
|
|
| if integration = Repo.one(query) do |
| {:ok, integration} |
| else |
| {:error, :not_found} |
| end |
| end |
|
|
| @spec get_integration(String.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found} |
| def get_integration(identifier) when is_binary(identifier) do |
| query = integration_query() |> where([i], i.identifier == ^identifier) |
|
|
| if integration = Repo.one(query) do |
| {:ok, integration} |
| else |
| {:error, :not_found} |
| end |
| end |
|
|
| defp integration_query() do |
| from(i in SSO.Integration, |
| inner_join: t in assoc(i, :team), |
| as: :team, |
| left_join: d in assoc(i, :sso_domains), |
| as: :sso_domains, |
| preload: [team: t, sso_domains: d] |
| ) |
| end |
|
|
| @spec initiate_saml_integration(Teams.Team.t()) :: SSO.Integration.t() |
| def initiate_saml_integration(team) do |
| changeset = SSO.Integration.init_changeset(team) |
|
|
| Repo.insert_with_audit!( |
| changeset, |
| "saml_integration_initiated", |
| %{team_id: team.id}, |
| on_conflict: [set: [updated_at: NaiveDateTime.utc_now(:second)]], |
| conflict_target: :team_id, |
| returning: true |
| ) |
| end |
|
|
| @spec update_integration(SSO.Integration.t(), map()) :: |
| {:ok, SSO.Integration.t()} | {:error, Ecto.Changeset.t()} |
| def update_integration(integration, params) do |
| changeset = SSO.Integration.update_changeset(integration, params) |
|
|
| case Repo.update_with_audit(changeset, "sso_integration_updated", %{ |
| team_id: integration.team_id |
| }) do |
| {:ok, integration} -> {:ok, integration} |
| {:error, changeset} -> {:error, changeset.changes.config} |
| end |
| end |
|
|
| @spec provision_user(SSO.Identity.t()) :: |
| {:ok, :standard | :sso | :integration, Teams.Team.t(), Auth.User.t()} |
| | {:error, :integration_not_found | :over_limit} |
| | {:error, :multiple_memberships | :active_personal_team, Teams.Team.t(), Auth.User.t()} |
| def provision_user(identity) do |
| case find_user(identity) do |
| {:ok, :standard, user, integration, domain} -> |
| provision_standard_user(user, identity, integration, domain) |
|
|
| {:ok, :sso, user, integration, domain} -> |
| provision_sso_user(user, identity, integration, domain) |
|
|
| {:ok, :integration, _, integration, domain} -> |
| provision_identity(identity, integration, domain) |
|
|
| {:error, :not_found} -> |
| {:error, :integration_not_found} |
| end |
| end |
|
|
| @spec deprovision_user!(Auth.User.t()) :: Auth.User.t() |
| def deprovision_user!(%{type: :standard} = user), do: user |
|
|
| def deprovision_user!(user) do |
| user = Repo.preload(user, [:sso_integration, :sso_domain]) |
|
|
| :ok = Auth.UserSessions.revoke_all(user) |
|
|
| user |
| |> Ecto.Changeset.change() |
| |> Ecto.Changeset.put_change(:type, :standard) |
| |> Ecto.Changeset.put_change(:sso_identity_id, nil) |
| |> Ecto.Changeset.put_assoc(:sso_integration, nil) |
| |> Ecto.Changeset.put_assoc(:sso_domain, nil) |
| |> Repo.update_with_audit!("sso_user_deprovioned", %{team_id: user.sso_integration.team_id}) |
| end |
|
|
| @spec update_policy(Teams.Team.t(), [policy_attr()]) :: |
| {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()} |
| def update_policy(team, attrs \\ []) do |
| params = Map.new(attrs) |
|
|
| changeset = |
| team |
| |> Ecto.Changeset.cast(%{policy: params}, []) |
| |> Ecto.Changeset.cast_embed(:policy, with: &Teams.Policy.update_changeset/2) |
|
|
| case Repo.update_with_audit(changeset, "sso_policy_updated", %{team_id: team.id}) do |
| {:ok, integration} -> {:ok, integration} |
| {:error, changeset} -> {:error, changeset.changes.policy} |
| end |
| end |
|
|
| @spec set_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: |
| {:ok, Teams.Team.t()} |
| | {:error, |
| :no_integration |
| | :no_domain |
| | :no_verified_domain |
| | :owner_2fa_disabled |
| | :no_sso_user} |
| def set_force_sso(team, mode) do |
| with :ok <- check_force_sso(team, mode) do |
| params = %{policy: %{force_sso: mode}} |
|
|
| team |
| |> Ecto.Changeset.cast(params, []) |
| |> Ecto.Changeset.cast_embed(:policy, |
| with: &Teams.Policy.force_sso_changeset(&1, &2.force_sso) |
| ) |
| |> Repo.update_with_audit("sso_force_mode_changed", %{team_id: team.id}) |
| end |
| end |
|
|
| @spec check_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: |
| :ok |
| | {:error, |
| :no_integration |
| | :no_domain |
| | :no_verified_domain |
| | :owner_2fa_disabled |
| | :no_sso_user} |
| def check_force_sso(_team, :none), do: :ok |
|
|
| def check_force_sso(team, :all_but_owners) do |
| with :ok <- check_integration_configured(team), |
| :ok <- check_sso_user_present(team) do |
| check_owners_2fa_enabled(team) |
| end |
| end |
|
|
| @spec check_can_remove_integration(SSO.Integration.t()) :: |
| :ok | {:error, :force_sso_enabled | :sso_users_present} |
| def check_can_remove_integration(integration) do |
| team = Repo.preload(integration, :team).team |
|
|
| cond do |
| team.policy.force_sso != :none -> |
| {:error, :force_sso_enabled} |
|
|
| check_sso_user_present(integration) == :ok -> |
| {:error, :sso_users_present} |
|
|
| true -> |
| :ok |
| end |
| end |
|
|
| @spec remove_integration(SSO.Integration.t(), Keyword.t()) :: |
| :ok | {:error, :force_sso_enabled | :sso_users_present} |
| def remove_integration(integration, opts \\ []) do |
| force_deprovision? = Keyword.get(opts, :force_deprovision?, false) |
| check = check_can_remove_integration(integration) |
|
|
| case {check, force_deprovision?} do |
| {:ok, _} -> |
| {:ok, :ok} = |
| Repo.transaction(fn -> |
| integration = Repo.preload(integration, :sso_domains) |
| Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain)) |
|
|
| Repo.delete_with_audit!(integration, "sso_integration_removed", %{ |
| team_id: integration.team_id |
| }) |
|
|
| :ok |
| end) |
|
|
| :ok |
|
|
| {{:error, :sso_users_present}, true} -> |
| {:ok, :ok} = |
| Repo.transaction(fn -> |
| users = Repo.preload(integration, :users).users |
| integration = Repo.preload(integration, :sso_domains) |
| Enum.each(users, &deprovision_user!/1) |
| Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain)) |
|
|
| Repo.delete_with_audit!(integration, "sso_integration_removed", %{ |
| team_id: integration.team_id |
| }) |
|
|
| :ok |
| end) |
|
|
| :ok |
|
|
| {{:error, error}, _} -> |
| {:error, error} |
| end |
| end |
|
|
| @spec check_ready_to_provision(Auth.User.t(), Teams.Team.t()) :: |
| :ok | {:error, :not_a_member | :multiple_memberships | :active_personal_team} |
| def check_ready_to_provision(%{type: :sso} = _user, _team), do: :ok |
|
|
| def check_ready_to_provision(user, team) do |
| result = |
| with :ok <- ensure_team_member(team, user), |
| :ok <- ensure_one_membership(user, team) do |
| ensure_empty_personal_team(user, team) |
| end |
|
|
| case result do |
| :ok -> :ok |
| {:error, :integration_not_found} -> {:error, :not_a_member} |
| {:error, :multiple_memberships, _, _} -> {:error, :multiple_memberships} |
| {:error, :active_personal_team, _, _} -> {:error, :active_personal_team} |
| end |
| end |
|
|
| defp check_integration_configured(team) do |
| integrations = |
| Repo.all( |
| from( |
| i in SSO.Integration, |
| left_join: d in assoc(i, :sso_domains), |
| where: i.team_id == ^team.id, |
| preload: [sso_domains: d] |
| ) |
| ) |
|
|
| domains = Enum.flat_map(integrations, & &1.sso_domains) |
| no_verified_domains? = Enum.all?(domains, &(&1.status != Status.verified())) |
|
|
| cond do |
| integrations == [] -> {:error, :no_integration} |
| domains == [] -> {:error, :no_domain} |
| no_verified_domains? -> {:error, :no_verified_domain} |
| true -> :ok |
| end |
| end |
|
|
| defp check_sso_user_present(%Teams.Team{} = team) do |
| sso_user_count = |
| Repo.aggregate( |
| from( |
| tm in Teams.Membership, |
| inner_join: u in assoc(tm, :user), |
| where: tm.team_id == ^team.id, |
| where: tm.role != :guest, |
| where: u.type == :sso |
| ), |
| :count |
| ) |
|
|
| if sso_user_count > 0 do |
| :ok |
| else |
| {:error, :no_sso_user} |
| end |
| end |
|
|
| defp check_sso_user_present(%SSO.Integration{} = integration) do |
| sso_user_count = |
| Repo.aggregate( |
| from( |
| i in SSO.Integration, |
| inner_join: u in assoc(i, :users), |
| inner_join: tm in assoc(u, :team_memberships), |
| on: tm.team_id == i.team_id, |
| where: i.id == ^integration.id, |
| where: tm.role != :guest, |
| where: u.type == :sso |
| ), |
| :count |
| ) |
|
|
| if sso_user_count > 0 do |
| :ok |
| else |
| {:error, :no_sso_user} |
| end |
| end |
|
|
| defp check_owners_2fa_enabled(team) do |
| disabled_2fa_count = |
| Repo.aggregate( |
| from( |
| tm in Teams.Membership, |
| inner_join: u in assoc(tm, :user), |
| where: tm.team_id == ^team.id, |
| where: tm.role == :owner, |
| where: u.totp_enabled == false or is_nil(u.totp_secret) |
| ), |
| :count |
| ) |
|
|
| if disabled_2fa_count == 0 do |
| :ok |
| else |
| {:error, :owner_2fa_disabled} |
| end |
| end |
|
|
| defp find_user(identity) do |
| case find_user_with_fallback(identity) do |
| {:ok, type, user, integration, domain} -> |
| {:ok, type, Repo.preload(user, [:sso_integration, :sso_domain]), integration, domain} |
|
|
| {:error, _} = error -> |
| error |
| end |
| end |
|
|
| defp find_user_with_fallback(identity) do |
| with {:ok, integration} <- get_integration(identity.integration_id), |
| {:error, :not_found} <- find_by_identity(identity, integration) do |
| find_by_email(identity.email, integration) |
| end |
| end |
|
|
| defp find_by_identity(identity, integration) do |
| if user = |
| Repo.get_by(Auth.User, |
| sso_integration_id: integration.id, |
| sso_identity_id: identity.id, |
| type: :sso |
| ) do |
| with {:ok, sso_domain} <- SSO.Domains.lookup(identity.email), |
| :ok <- check_domain_integration_match(sso_domain, integration) do |
| user = Repo.preload(user, sso_integration: :team) |
|
|
| {:ok, user.type, user, user.sso_integration, sso_domain} |
| end |
| else |
| {:error, :not_found} |
| end |
| end |
|
|
| defp find_by_email(email, integration) do |
| with {:ok, sso_domain} <- SSO.Domains.lookup(email), |
| :ok <- check_domain_integration_match(sso_domain, integration) do |
| case find_in_team_by_email(sso_domain.sso_integration.team, email) do |
| {:ok, user} -> |
| {:ok, user.type, user, sso_domain.sso_integration, sso_domain} |
|
|
| {:error, :not_found} -> |
| {:ok, :integration, nil, sso_domain.sso_integration, sso_domain} |
| end |
| end |
| end |
|
|
| defp find_in_team_by_email(team, email) do |
| result = |
| Repo.one( |
| from( |
| u in Auth.User, |
| inner_join: tm in assoc(u, :team_memberships), |
| where: u.email == ^email, |
| where: tm.team_id == ^team.id, |
| where: tm.role != :guest |
| ) |
| ) |
|
|
| if result do |
| {:ok, result} |
| else |
| {:error, :not_found} |
| end |
| end |
|
|
| defp check_domain_integration_match(sso_domain, integration) do |
| if sso_domain.sso_integration_id == integration.id do |
| :ok |
| else |
| {:error, :not_found} |
| end |
| end |
|
|
| defp provision_sso_user(user, identity, integration, domain) do |
| changeset = |
| user |
| |> change() |
| |> put_change(:email, identity.email) |
| |> put_change(:name, identity.name) |
| |> put_change(:sso_identity_id, identity.id) |
| |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) |
| |> put_assoc(:sso_domain, domain) |
|
|
| with {:ok, user} <- |
| Repo.update_with_audit(changeset, "sso_user_provisioned", %{ |
| team_id: integration.team_id |
| }) do |
| {:ok, :sso, integration.team, user} |
| end |
| end |
|
|
| defp provision_standard_user(user, identity, integration, domain) do |
| changeset = |
| user |
| |> change() |
| |> put_change(:type, :sso) |
| |> put_change(:name, identity.name) |
| |> put_change(:sso_identity_id, identity.id) |
| |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) |
| |> put_assoc(:sso_integration, integration) |
| |> put_assoc(:sso_domain, domain) |
|
|
| with :ok <- ensure_team_member(integration.team, user), |
| :ok <- ensure_one_membership(user, integration.team), |
| :ok <- ensure_empty_personal_team(user, integration.team), |
| :ok <- Auth.UserSessions.revoke_all(user), |
| {:ok, user} <- |
| Repo.update_with_audit(changeset, "sso_user_provisioned", %{ |
| team_id: integration.team_id |
| }) do |
| {:ok, :standard, integration.team, user} |
| end |
| end |
|
|
| defp provision_identity(identity, integration, domain) do |
| random_password = |
| 64 |
| |> :crypto.strong_rand_bytes() |
| |> Base.encode64(padding: false) |
|
|
| params = %{ |
| email: identity.email, |
| name: identity.name, |
| password: random_password, |
| password_confirmation: random_password |
| } |
|
|
| changeset = |
| Auth.User.new(params) |
| |> put_change(:email_verified, true) |
| |> put_change(:type, :sso) |
| |> put_change(:sso_identity_id, identity.id) |
| |> put_change(:last_sso_login, NaiveDateTime.utc_now(:second)) |
| |> put_assoc(:sso_integration, integration) |
| |> put_assoc(:sso_domain, domain) |
|
|
| team = integration.team |
| role = integration.team.policy.sso_default_role |
| now = NaiveDateTime.utc_now(:second) |
|
|
| result = |
| Repo.transaction(fn -> |
| with {:ok, user} <- |
| Repo.insert_with_audit(changeset, "sso_user_provisioned", %{team_id: team.id}), |
| :ok <- Teams.Invitations.check_team_member_limit(team, role, user.email), |
| {:ok, team_membership} <- |
| Teams.Invitations.create_team_membership(team, role, user, now) do |
| if team_membership.role != :guest do |
| {:identity, team, user} |
| else |
| Repo.rollback(:integration_not_found) |
| end |
| else |
| {:error, %{errors: [email: {_, attrs}]}} -> |
| true = {:constraint, :unique} in attrs |
| Repo.rollback(:integration_not_found) |
|
|
| {:error, {:over_limit, _}} -> |
| Repo.rollback(:over_limit) |
| end |
| end) |
|
|
| case result do |
| {:ok, {type, team, user}} -> |
| {:ok, type, team, user} |
|
|
| {:error, _} = error -> |
| error |
| end |
| end |
|
|
| defp ensure_team_member(team, user) do |
| case Teams.Memberships.team_role(team, user) do |
| {:ok, role} when role != :guest -> |
| :ok |
|
|
| _ -> |
| {:error, :integration_not_found} |
| end |
| end |
|
|
| defp ensure_one_membership(user, team) do |
| if Teams.Users.team_member?(user, except: [team.id], only_setup?: true) do |
| {:error, :multiple_memberships, team, user} |
| else |
| :ok |
| end |
| end |
|
|
| defp ensure_empty_personal_team(user, team) do |
| case Teams.get_by_owner(user, only_not_setup?: true) do |
| {:ok, personal_team} -> |
| subscription = Teams.Billing.get_subscription(personal_team) |
|
|
| no_subscription? = |
| is_nil(subscription) or subscription.status == Subscription.Status.deleted() |
|
|
| zero_sites? = Teams.owned_sites_count(personal_team) == 0 |
|
|
| if no_subscription? and zero_sites? do |
| :ok |
| else |
| {:error, :active_personal_team, team, user} |
| end |
|
|
| {:error, :no_team} -> |
| :ok |
| end |
| end |
| end |
|
|