Audit trail for SSO (#5560)
* ✨ * wip * wip * Moduledoc false * wip * Update extra/lib/plausible/auth/sso/saml_config.ex Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com> * Include only data keys present in changes * Improve audit logging for SSO domain verification Make it more compact and hopefully more readable to CS * Harden existing tests * Use consistent naming * Update audit entries migration: use UUIDs for primary keys * Fix up tests * Format * Only test audit for EE * Remove temporary String.Chars implementation * Always log keys as per `derive` directive; include changes for inserts * Write `actor_type` to audit entries * Extract Audit.Repo functions * Moduledocs * Include change in audited deletions * Make audit available only in EE build A bit clunky? cc @zoldar * Put test behind ee compilation flag * Pin user e-mail in test * Ensure encoder opts are passed for nested calls * Carry `__allow_not_loaded__` even if no extractor defined * Turn `actor_type` into an ecto enum type * Remove unused function * s/sso_forced/sso_force_mode_changed * Unwrap single item list for protocol implementation Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com> * Migration: audit entries (#5581) * Migration: audit entries * Put migration behind EE conditional --------- Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
parent
ca2611fc83
commit
adf39ca7a8
|
|
@ -0,0 +1,16 @@
|
|||
defmodule Plausible.Audit do
|
||||
@moduledoc """
|
||||
Primary persistent Audit Entry interface
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
defdelegate encode(term, opts \\ []), to: Plausible.Audit.Encoder
|
||||
defdelegate set_context(term), to: Plausible.Audit.Entry
|
||||
|
||||
def list_entries(attrs) do
|
||||
Plausible.Repo.all(
|
||||
from ae in Plausible.Audit.Entry, where: ^attrs, order_by: [asc: :datetime]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
defmodule Plausible.Audit.EncoderError do
|
||||
defexception [:message]
|
||||
end
|
||||
|
||||
defprotocol Plausible.Audit.Encoder do
|
||||
def encode(x, opts \\ [])
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: Ecto.Changeset do
|
||||
def encode(changeset, opts) do
|
||||
changes =
|
||||
Enum.reduce(changeset.changes, %{}, fn {k, v}, acc ->
|
||||
Map.put(acc, k, Plausible.Audit.Encoder.encode(v, opts))
|
||||
end)
|
||||
|
||||
data = Plausible.Audit.Encoder.encode(changeset.data, opts)
|
||||
|
||||
case {map_size(data), map_size(changes)} do
|
||||
{n, 0} when n > 0 ->
|
||||
data
|
||||
|
||||
{0, n} when n > 0 ->
|
||||
changes
|
||||
|
||||
{0, 0} ->
|
||||
%{}
|
||||
|
||||
_ ->
|
||||
%{before: data, after: changes}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: Map do
|
||||
def encode(x, opts) do
|
||||
{allow_not_loaded, data} = Map.pop(x, :__allow_not_loaded__)
|
||||
raise_on_not_loaded? = Keyword.get(opts, :raise_on_not_loaded?, true)
|
||||
|
||||
Enum.reduce(data, %{}, fn
|
||||
{k, %Ecto.Association.NotLoaded{}}, acc ->
|
||||
if k in allow_not_loaded or not raise_on_not_loaded? do
|
||||
acc
|
||||
else
|
||||
raise Plausible.Audit.EncoderError,
|
||||
message:
|
||||
"#{k} association not loaded. Either preload, exclude or mark it as :allow_not_loaded in #{__MODULE__} options"
|
||||
end
|
||||
|
||||
{k, v}, acc ->
|
||||
Map.put(acc, k, Plausible.Audit.Encoder.encode(v, opts))
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: [Integer, BitString, Float] do
|
||||
def encode(x, _opts), do: x
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: [DateTime, Date, NaiveDateTime, Time] do
|
||||
def encode(x, _opts), do: to_string(x)
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: Atom do
|
||||
def encode(nil, _opts), do: nil
|
||||
def encode(true, _opts), do: true
|
||||
def encode(false, _opts), do: false
|
||||
def encode(x, _opts), do: Atom.to_string(x)
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: List do
|
||||
def encode(x, opts) do
|
||||
Enum.map(x, &Plausible.Audit.Encoder.encode(&1, opts))
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Plausible.Audit.Encoder, for: Any do
|
||||
defmacro __deriving__(module, struct, options) do
|
||||
deriving(module, struct, options)
|
||||
end
|
||||
|
||||
def deriving(module, _struct, options) do
|
||||
only = options[:only]
|
||||
except = options[:except]
|
||||
allow_not_loaded = options[:allow_not_loaded] || []
|
||||
|
||||
extractor =
|
||||
cond do
|
||||
only ->
|
||||
quote(
|
||||
do:
|
||||
struct
|
||||
|> Map.take(unquote(only))
|
||||
|> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded))
|
||||
)
|
||||
|
||||
except ->
|
||||
except = [:__struct__ | except]
|
||||
|
||||
quote(
|
||||
do:
|
||||
struct
|
||||
|> Map.drop(
|
||||
unquote(except)
|
||||
|> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded))
|
||||
)
|
||||
)
|
||||
|
||||
true ->
|
||||
quote(
|
||||
do:
|
||||
struct
|
||||
|> Map.delete(:__struct__)
|
||||
|> Map.put(:__allow_not_loaded__, unquote(allow_not_loaded))
|
||||
)
|
||||
end
|
||||
|
||||
quote do
|
||||
defimpl Plausible.Audit.Encoder, for: unquote(module) do
|
||||
def encode(struct, opts) do
|
||||
Plausible.Audit.Encoder.encode(unquote(extractor), opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def encode(_, _), do: raise("Implement me")
|
||||
end
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
defmodule Plausible.Audit.Entry do
|
||||
@moduledoc """
|
||||
Persistent Audit Entry schema
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
entity: String.t(),
|
||||
entity_id: String.t(),
|
||||
meta: map(),
|
||||
change: map(),
|
||||
user_id: integer(),
|
||||
team_id: integer(),
|
||||
datetime: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "audit_entries" do
|
||||
field :name, :string
|
||||
field :entity, :string
|
||||
field :entity_id, :string
|
||||
field :meta, :map
|
||||
field :change, :map, default: %{}
|
||||
# default 0 is still useful in tests?
|
||||
field :user_id, :integer, default: 0
|
||||
field :team_id, :integer, default: 0
|
||||
field :datetime, :naive_datetime_usec
|
||||
field :actor_type, Ecto.Enum, default: :system, values: [:system, :user]
|
||||
end
|
||||
|
||||
def changeset(name, params) do
|
||||
context = get_context()
|
||||
|
||||
params =
|
||||
Map.merge(
|
||||
%{
|
||||
team_id: context[:current_team] && context.current_team.id,
|
||||
user_id: context[:current_user] && context.current_user.id,
|
||||
actor_type: if(context[:current_user], do: "user", else: "system")
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
%__MODULE__{name: name}
|
||||
|> cast(params, [:entity, :entity_id, :meta, :user_id, :team_id, :actor_type])
|
||||
|> validate_required([:name, :entity, :entity_id, :actor_type])
|
||||
|> put_change(:datetime, NaiveDateTime.utc_now())
|
||||
end
|
||||
|
||||
def new(name, %{__struct__: struct, id: id}, params \\ %{}) do
|
||||
changeset(name, Map.merge(%{entity: to_str(struct), entity_id: to_str(id)}, params))
|
||||
end
|
||||
|
||||
def include_change(audit_entry, %Ecto.Changeset{} = related_changeset) do
|
||||
audit_entry
|
||||
|> change()
|
||||
|> put_change(:change, Plausible.Audit.encode(related_changeset))
|
||||
end
|
||||
|
||||
def include_change(audit_entry, %{__struct__: _} = struct) do
|
||||
# inserts hardly ever preload associations, so raising on not loaded is not useful
|
||||
audit_entry
|
||||
|> change()
|
||||
|> put_change(:change, Plausible.Audit.encode(struct, raise_on_not_loaded?: false))
|
||||
end
|
||||
|
||||
def persist!(entry) do
|
||||
Plausible.Repo.insert!(entry)
|
||||
end
|
||||
|
||||
defp get_context() do
|
||||
case :logger.get_process_metadata() do
|
||||
%{:__audit__ => audit_context} -> audit_context
|
||||
%{} -> %{}
|
||||
:undefined -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def set_context(kv) when is_map(kv) do
|
||||
:logger.update_process_metadata(%{:__audit__ => kv})
|
||||
end
|
||||
|
||||
defp to_str(x) when is_binary(x), do: x
|
||||
defp to_str(x) when is_atom(x), do: inspect(x)
|
||||
defp to_str(x), do: to_string(x)
|
||||
end
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Plausible.Audit.LiveContext do
|
||||
@moduledoc """
|
||||
LiveView `on_mount` callback to provide audit context
|
||||
"""
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
on_mount Plausible.Audit.LiveContext
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:default, _params, _session, socket) do
|
||||
if Phoenix.LiveView.connected?(socket) do
|
||||
Plausible.Audit.set_context(%{
|
||||
current_user: socket.assigns[:current_user],
|
||||
current_team: socket.assigns[:current_team]
|
||||
})
|
||||
end
|
||||
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
|
@ -56,7 +56,10 @@ defmodule Plausible.Auth.SSO do
|
|||
def initiate_saml_integration(team) do
|
||||
changeset = SSO.Integration.init_changeset(team)
|
||||
|
||||
Repo.insert!(changeset,
|
||||
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
|
||||
|
|
@ -68,7 +71,9 @@ defmodule Plausible.Auth.SSO do
|
|||
def update_integration(integration, params) do
|
||||
changeset = SSO.Integration.update_changeset(integration, params)
|
||||
|
||||
case Repo.update(changeset) do
|
||||
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
|
||||
|
|
@ -108,7 +113,7 @@ defmodule Plausible.Auth.SSO do
|
|||
|> Ecto.Changeset.put_change(:sso_identity_id, nil)
|
||||
|> Ecto.Changeset.put_assoc(:sso_integration, nil)
|
||||
|> Ecto.Changeset.put_assoc(:sso_domain, nil)
|
||||
|> Repo.update!()
|
||||
|> Repo.update_with_audit!("sso_user_deprovioned", %{team_id: user.sso_integration.team_id})
|
||||
end
|
||||
|
||||
@spec update_policy(Teams.Team.t(), [policy_attr()]) ::
|
||||
|
|
@ -122,7 +127,7 @@ defmodule Plausible.Auth.SSO do
|
|||
|> Ecto.Changeset.change()
|
||||
|> Ecto.Changeset.put_embed(:policy, policy_changeset)
|
||||
|
||||
case Repo.update(changeset) do
|
||||
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
|
||||
|
|
@ -143,7 +148,7 @@ defmodule Plausible.Auth.SSO do
|
|||
team
|
||||
|> Ecto.Changeset.change()
|
||||
|> Ecto.Changeset.put_embed(:policy, policy_changeset)
|
||||
|> Repo.update()
|
||||
|> Repo.update_with_audit("sso_force_mode_changed", %{team_id: team.id})
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -193,7 +198,11 @@ defmodule Plausible.Auth.SSO do
|
|||
Repo.transaction(fn ->
|
||||
integration = Repo.preload(integration, :sso_domains)
|
||||
Enum.each(integration.sso_domains, &SSO.Domains.cancel_verification(&1.domain))
|
||||
Repo.delete!(integration)
|
||||
|
||||
Repo.delete_with_audit!(integration, "sso_integration_removed", %{
|
||||
team_id: integration.team_id
|
||||
})
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
|
|
@ -206,7 +215,11 @@ defmodule Plausible.Auth.SSO do
|
|||
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!(integration)
|
||||
|
||||
Repo.delete_with_audit!(integration, "sso_integration_removed", %{
|
||||
team_id: integration.team_id
|
||||
})
|
||||
|
||||
:ok
|
||||
end)
|
||||
|
||||
|
|
@ -405,7 +418,10 @@ defmodule Plausible.Auth.SSO do
|
|||
|> put_change(:last_sso_login, NaiveDateTime.utc_now(:second))
|
||||
|> put_assoc(:sso_domain, domain)
|
||||
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
with {:ok, user} <-
|
||||
Repo.update_with_audit(changeset, "sso_user_provisioned", %{
|
||||
team_id: integration.team_id
|
||||
}) do
|
||||
{:ok, :sso, integration.team, user}
|
||||
end
|
||||
end
|
||||
|
|
@ -425,7 +441,10 @@ defmodule Plausible.Auth.SSO do
|
|||
: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(changeset) do
|
||||
{:ok, user} <-
|
||||
Repo.update_with_audit(changeset, "sso_user_provisioned", %{
|
||||
team_id: integration.team_id
|
||||
}) do
|
||||
{:ok, :standard, integration.team, user}
|
||||
end
|
||||
end
|
||||
|
|
@ -458,7 +477,8 @@ defmodule Plausible.Auth.SSO do
|
|||
|
||||
result =
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, user} <- Repo.insert(changeset),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule Plausible.Auth.SSO.Domain do
|
|||
recorded.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
|
@ -27,6 +28,12 @@ defmodule Plausible.Auth.SSO.Domain do
|
|||
|
||||
use Plausible.Auth.SSO.Domain.Status
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder,
|
||||
only: [:id, :identifier, :domain, :verified_via, :status, :sso_integration],
|
||||
allow_not_loaded: [:sso_integration]}
|
||||
end
|
||||
|
||||
schema "sso_domains" do
|
||||
field :identifier, Ecto.UUID
|
||||
field :domain, :string
|
||||
|
|
@ -56,7 +63,7 @@ defmodule Plausible.Auth.SSO.Domain do
|
|||
|
||||
@spec verified_changeset(t(), verification_method(), NaiveDateTime.t()) ::
|
||||
Ecto.Changeset.t()
|
||||
def verified_changeset(sso_domain, method, now) do
|
||||
def verified_changeset(sso_domain, method, now \\ NaiveDateTime.utc_now(:second)) do
|
||||
sso_domain
|
||||
|> change()
|
||||
|> put_change(:verified_via, method)
|
||||
|
|
@ -64,8 +71,12 @@ defmodule Plausible.Auth.SSO.Domain do
|
|||
|> put_change(:status, Status.verified())
|
||||
end
|
||||
|
||||
@spec unverified_changeset(t(), NaiveDateTime.t(), atom()) :: Ecto.Changeset.t()
|
||||
def unverified_changeset(sso_domain, now, status \\ Status.in_progress()) do
|
||||
@spec unverified_changeset(t(), atom(), NaiveDateTime.t()) :: Ecto.Changeset.t()
|
||||
def unverified_changeset(
|
||||
sso_domain,
|
||||
status \\ Status.in_progress(),
|
||||
now \\ NaiveDateTime.utc_now(:second)
|
||||
) do
|
||||
sso_domain
|
||||
|> change()
|
||||
|> put_change(:verified_via, nil)
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ defmodule Plausible.Auth.SSO.Domain.Verification.Worker do
|
|||
defp verification_failure(domain) do
|
||||
with {:ok, sso_domain} <- SSO.Domains.get(domain) do
|
||||
sso_domain
|
||||
|> SSO.Domains.mark_unverified!(Status.unverified())
|
||||
|> SSO.Domains.mark_verification_failure!()
|
||||
|> send_failure_notification()
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
def add(integration, domain) do
|
||||
changeset = SSO.Domain.create_changeset(integration, domain)
|
||||
|
||||
Repo.insert(changeset)
|
||||
Repo.insert_with_audit(changeset, "sso_domain_added", %{team_id: integration.team_id})
|
||||
end
|
||||
|
||||
@spec start_verification(String.t()) :: SSO.Domain.t()
|
||||
|
|
@ -25,7 +25,14 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
{:ok, result} =
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, sso_domain} <- get(domain) do
|
||||
sso_domain = mark_unverified!(sso_domain, Status.in_progress())
|
||||
sso_domain =
|
||||
sso_domain
|
||||
|> SSO.Domain.unverified_changeset(Status.in_progress())
|
||||
|> Repo.update_with_audit!(
|
||||
"sso_domain_verification_started",
|
||||
%{team_id: sso_domain.sso_integration.team_id}
|
||||
)
|
||||
|
||||
{:ok, _} = Verification.Worker.enqueue(domain)
|
||||
{:ok, sso_domain}
|
||||
end
|
||||
|
|
@ -39,7 +46,11 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
{:ok, :ok} =
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, sso_domain} <- get(domain) do
|
||||
mark_unverified!(sso_domain, Status.unverified())
|
||||
sso_domain
|
||||
|> SSO.Domain.unverified_changeset(Status.unverified())
|
||||
|> Repo.update_with_audit("sso_domain_verification_cancelled", %{
|
||||
team_id: sso_domain.sso_integration.team_id
|
||||
})
|
||||
end
|
||||
|
||||
:ok = Verification.Worker.cancel(domain)
|
||||
|
|
@ -66,7 +77,9 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
mark_verified!(sso_domain, step, now)
|
||||
|
||||
{:error, :unverified} ->
|
||||
mark_unverified!(sso_domain, :in_progress, now)
|
||||
sso_domain
|
||||
|> SSO.Domain.unverified_changeset(Status.in_progress(), now)
|
||||
|> Repo.update!()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -175,14 +188,18 @@ defmodule Plausible.Auth.SSO.Domains do
|
|||
def mark_verified!(sso_domain, method, now \\ NaiveDateTime.utc_now(:second)) do
|
||||
sso_domain
|
||||
|> SSO.Domain.verified_changeset(method, now)
|
||||
|> Repo.update!()
|
||||
|> Repo.update_with_audit!("sso_domain_verification_success", %{
|
||||
team_id: sso_domain.sso_integration.team_id
|
||||
})
|
||||
end
|
||||
|
||||
@spec mark_unverified!(SSO.Domain.t(), atom(), NaiveDateTime.t()) :: SSO.Domain.t()
|
||||
def mark_unverified!(sso_domain, status, now \\ NaiveDateTime.utc_now(:second)) do
|
||||
@spec mark_verification_failure!(SSO.Domain.t()) :: SSO.Domain.t()
|
||||
def mark_verification_failure!(sso_domain) do
|
||||
sso_domain
|
||||
|> SSO.Domain.unverified_changeset(now, status)
|
||||
|> Repo.update!()
|
||||
|> SSO.Domain.unverified_changeset(Status.unverified())
|
||||
|> Repo.update_with_audit!("sso_domain_verification_failure", %{
|
||||
team_id: sso_domain.sso_integration.team_id
|
||||
})
|
||||
end
|
||||
|
||||
defp users_by_domain(sso_domain) do
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Plausible.Auth.SSO.Integration do
|
|||
when configuring external services like IdPs.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
|
@ -20,6 +21,10 @@ defmodule Plausible.Auth.SSO.Integration do
|
|||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder, only: [:id, :identifier]}
|
||||
end
|
||||
|
||||
schema "sso_integrations" do
|
||||
field :identifier, Ecto.UUID
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule Plausible.Auth.SSO.SAMLConfig do
|
|||
from IdP again.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
use Ecto.Schema
|
||||
|
||||
alias Plausible.Auth.SSO
|
||||
|
|
@ -21,6 +22,11 @@ defmodule Plausible.Auth.SSO.SAMLConfig do
|
|||
@fields [:idp_signin_url, :idp_entity_id, :idp_cert_pem, :idp_metadata]
|
||||
@required_fields @fields -- [:idp_metadata]
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder,
|
||||
only: [:id, :idp_signin_url, :idp_entity_id, :idp_cert_pem, :idp_metadata]}
|
||||
end
|
||||
|
||||
embedded_schema do
|
||||
field :idp_signin_url, :string
|
||||
field :idp_entity_id, :string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
defmodule Plausible.Audit.Repo do
|
||||
@moduledoc """
|
||||
Equips Ecto.Repo with audited insert/update/delete variants.
|
||||
This module will potentially augment db operations with transaction wrappers.
|
||||
|
||||
Audit is EE-specific, so CE gets only no-op adapter functions.
|
||||
"""
|
||||
use Plausible
|
||||
|
||||
@callback update_with_audit(
|
||||
changeset :: Ecto.Changeset.t(),
|
||||
entry_name :: any(),
|
||||
params :: map()
|
||||
) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||
|
||||
@callback update_with_audit!(
|
||||
changeset :: Ecto.Changeset.t(),
|
||||
entry_name :: any(),
|
||||
params :: map()
|
||||
) :: Ecto.Schema.t()
|
||||
|
||||
@callback insert_with_audit(
|
||||
changeset :: Ecto.Changeset.t(),
|
||||
entry_name :: any(),
|
||||
params :: map()
|
||||
) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||
|
||||
@callback insert_with_audit!(
|
||||
changeset :: Ecto.Changeset.t(),
|
||||
entry_name :: any(),
|
||||
params :: map(),
|
||||
insert_opts :: keyword()
|
||||
) :: Ecto.Schema.t()
|
||||
|
||||
@callback delete_with_audit!(
|
||||
resource :: Ecto.Schema.t() | Ecto.Changeset.t(),
|
||||
entry_name :: any(),
|
||||
params :: map()
|
||||
) :: Ecto.Schema.t()
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
on_ee do
|
||||
quote do
|
||||
@behaviour Plausible.Audit.Repo
|
||||
|
||||
def update_with_audit(%Ecto.Changeset{} = changeset, entry_name, params \\ %{}) do
|
||||
case update(changeset) do
|
||||
{:ok, result} ->
|
||||
store_audit(entry_name, result, changeset, params)
|
||||
|
||||
{:ok, result}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def update_with_audit!(%Ecto.Changeset{} = changeset, entry_name, params \\ %{}) do
|
||||
result = update!(changeset)
|
||||
store_audit(entry_name, result, changeset, params)
|
||||
result
|
||||
end
|
||||
|
||||
def insert_with_audit(%Ecto.Changeset{} = changeset, entry_name, params \\ %{}) do
|
||||
case insert(changeset) do
|
||||
{:ok, result} ->
|
||||
store_audit(entry_name, result, params)
|
||||
|
||||
{:ok, result}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def insert_with_audit!(
|
||||
%Ecto.Changeset{} = changeset,
|
||||
entry_name,
|
||||
params \\ %{},
|
||||
insert_opts \\ []
|
||||
) do
|
||||
result = insert!(changeset, insert_opts)
|
||||
store_audit(entry_name, result, params)
|
||||
result
|
||||
end
|
||||
|
||||
def delete_with_audit!(resource, entry_name, params \\ %{}) do
|
||||
result = delete!(resource)
|
||||
store_audit(entry_name, resource, params)
|
||||
result
|
||||
end
|
||||
|
||||
defp store_audit(entry_name, result, changeset, params) do
|
||||
entry_name
|
||||
|> Plausible.Audit.Entry.new(result, params)
|
||||
|> Plausible.Audit.Entry.include_change(changeset)
|
||||
|> Plausible.Audit.Entry.persist!()
|
||||
end
|
||||
|
||||
defp store_audit(entry_name, result, params) do
|
||||
entry_name
|
||||
|> Plausible.Audit.Entry.new(result, params)
|
||||
|> Plausible.Audit.Entry.include_change(result)
|
||||
|> Plausible.Audit.Entry.persist!()
|
||||
end
|
||||
end
|
||||
else
|
||||
quote do
|
||||
@behaviour Plausible.Audit.Repo
|
||||
|
||||
def update_with_audit(changeset, _, _) do
|
||||
update(changeset)
|
||||
end
|
||||
|
||||
def update_with_audit!(changeset, _, _) do
|
||||
update!(changeset)
|
||||
end
|
||||
|
||||
def insert_with_audit(changeset, _, _) do
|
||||
insert(changeset)
|
||||
end
|
||||
|
||||
def insert_with_audit!(changeset, _, _, _) do
|
||||
insert!(changeset)
|
||||
end
|
||||
|
||||
def delete_with_audit!(resource, _, _) do
|
||||
delete!(resource)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -19,6 +19,21 @@ defmodule Plausible.Auth.User do
|
|||
|
||||
@required [:email, :name, :password]
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder,
|
||||
only: [
|
||||
:id,
|
||||
:email,
|
||||
:name,
|
||||
:email_verified,
|
||||
:previous_email,
|
||||
:totp_enabled,
|
||||
:last_team_identifier,
|
||||
:sso_integration,
|
||||
:sso_domain
|
||||
]}
|
||||
end
|
||||
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password_hash
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ defmodule Plausible.Repo do
|
|||
|
||||
use Scrivener, page_size: 24
|
||||
|
||||
use Plausible.Audit.Repo
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
alias Plausible.Repo
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule Plausible.Teams.Policy do
|
|||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
use Plausible
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
|
|
@ -22,6 +23,11 @@ defmodule Plausible.Teams.Policy do
|
|||
|
||||
@type force_sso_mode() :: unquote(Enum.reduce(@force_sso_modes, &{:|, [], [&1, &2]}))
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder,
|
||||
only: [:force_sso, :sso_default_role, :sso_session_timeout_minutes]}
|
||||
end
|
||||
|
||||
embedded_schema do
|
||||
# SSO options apply to all team's integrations, should there
|
||||
# ever be more than one allowed at once.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ defmodule Plausible.Teams.Team do
|
|||
@trial_accept_traffic_until_offset_days 14
|
||||
@subscription_accept_traffic_until_offset_days 30
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder,
|
||||
only: [
|
||||
:id,
|
||||
:identifier,
|
||||
:name,
|
||||
:trial_expiry_date,
|
||||
:accept_traffic_until,
|
||||
:allow_next_upgrade_override,
|
||||
:locked,
|
||||
:setup_complete,
|
||||
:setup_at,
|
||||
:hourly_api_request_limit,
|
||||
:policy
|
||||
]}
|
||||
end
|
||||
|
||||
schema "teams" do
|
||||
field :identifier, Ecto.UUID
|
||||
field :name, :string
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ defmodule PlausibleWeb do
|
|||
use PlausibleWeb.Live.SentryContext
|
||||
end
|
||||
|
||||
on_ee do
|
||||
use Plausible.Audit.LiveContext
|
||||
end
|
||||
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule PlausibleWeb.AuthPlug do
|
|||
Must be kept in sync with `PlausibleWeb.Live.AuthContext`.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
import Plug.Conn
|
||||
|
||||
alias PlausibleWeb.UserAuth
|
||||
|
|
@ -63,8 +64,16 @@ defmodule PlausibleWeb.AuthPlug do
|
|||
|> Enum.take(3)
|
||||
|
||||
Plausible.OpenTelemetry.add_user_attributes(user)
|
||||
|
||||
Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email})
|
||||
|
||||
on_ee do
|
||||
Plausible.Audit.set_context(%{
|
||||
current_user: user,
|
||||
current_team: current_team
|
||||
})
|
||||
end
|
||||
|
||||
conn
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:current_user_session, user_session)
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -76,7 +76,7 @@ defmodule Plausible.MixProject do
|
|||
{:cors_plug, "~> 3.0"},
|
||||
{:credo, "~> 1.5", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false},
|
||||
{:double, "~> 0.8.0", only: [:dev, :test, :ce_test]},
|
||||
{:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev]},
|
||||
{:ecto, "~> 3.12.0"},
|
||||
{:ecto_sql, "~> 3.12.0"},
|
||||
{:envy, "~> 1.1.1"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
defmodule Plausible.AuditTest do
|
||||
use Plausible
|
||||
|
||||
on_ee do
|
||||
use Plausible.DataCase
|
||||
|
||||
use Plausible.Teams.Test
|
||||
|
||||
alias Plausible.Audit
|
||||
alias Plausible.Audit.Encoder
|
||||
alias Plausible.Audit.Entry
|
||||
alias Plausible.Audit.TestSchema
|
||||
|
||||
describe "Audit.Encoder" do
|
||||
test "encodes integer, float, and bitstring as themselves" do
|
||||
assert Encoder.encode(42) == 42
|
||||
assert Encoder.encode(3.14) == 3.14
|
||||
assert Encoder.encode("hello") == "hello"
|
||||
end
|
||||
|
||||
test "encodes atoms" do
|
||||
assert Encoder.encode(:foo) == "foo"
|
||||
assert Encoder.encode(nil) == nil
|
||||
assert Encoder.encode(true) == true
|
||||
assert Encoder.encode(false) == false
|
||||
end
|
||||
|
||||
test "encodes Date, DateTime, NaiveDateTime, Time as strings" do
|
||||
dt = ~U[2024-06-01 12:00:00Z]
|
||||
date = ~D[2024-06-01]
|
||||
ndt = ~N[2024-06-01 12:00:00]
|
||||
time = ~T[12:00:00]
|
||||
assert Encoder.encode(dt) == to_string(dt)
|
||||
assert Encoder.encode(date) == to_string(date)
|
||||
assert Encoder.encode(ndt) == to_string(ndt)
|
||||
assert Encoder.encode(time) == to_string(time)
|
||||
end
|
||||
|
||||
test "encodes lists recursively" do
|
||||
assert Encoder.encode([:foo, 1, "bar"]) == ["foo", 1, "bar"]
|
||||
end
|
||||
|
||||
test "encodes map recursively" do
|
||||
map = %{foo: :bar, num: 1, nested: %{baz: :qux}}
|
||||
assert Encoder.encode(map) == %{foo: "bar", num: 1, nested: %{baz: "qux"}}
|
||||
end
|
||||
|
||||
test "raises if association not loaded and not allowed" do
|
||||
map = %{
|
||||
assoc: %Ecto.Association.NotLoaded{
|
||||
__field__: :assoc,
|
||||
__owner__: DummyStruct,
|
||||
__cardinality__: :one
|
||||
},
|
||||
__allow_not_loaded__: []
|
||||
}
|
||||
|
||||
assert_raise Audit.EncoderError, ~r/assoc association not loaded/, fn ->
|
||||
Encoder.encode(map)
|
||||
end
|
||||
end
|
||||
|
||||
test "skips not loaded association if allowed" do
|
||||
map = %{
|
||||
assoc: %Ecto.Association.NotLoaded{
|
||||
__field__: :assoc,
|
||||
__owner__: DummyStruct,
|
||||
__cardinality__: :one
|
||||
},
|
||||
__allow_not_loaded__: [:assoc]
|
||||
}
|
||||
|
||||
assert Encoder.encode(map) == %{}
|
||||
end
|
||||
|
||||
test "enforcing schema association present" do
|
||||
assert_raise Audit.EncoderError, fn ->
|
||||
Audit.encode(%TestSchema.VariantWithAssociation{id: 1})
|
||||
end
|
||||
|
||||
assert %{id: 1, team: "some"} =
|
||||
Audit.encode(%TestSchema.VariantWithAssociation{id: 1, team: :some})
|
||||
|
||||
assert %{id: 1} =
|
||||
Audit.encode(%TestSchema.VariantWithAssociationAllowNotLoaded{id: 1})
|
||||
|
||||
assert %{id: 1, team: "some"} =
|
||||
Audit.encode(%TestSchema.VariantWithAssociationAllowNotLoaded{
|
||||
id: 1,
|
||||
team: :some
|
||||
})
|
||||
end
|
||||
|
||||
test "returns data if only data is present" do
|
||||
data = %{foo: :bar}
|
||||
changeset = %Ecto.Changeset{data: data, changes: %{}}
|
||||
assert Encoder.encode(changeset) == %{foo: "bar"}
|
||||
end
|
||||
|
||||
test "returns changes if only changes are present" do
|
||||
changes = %{foo: :baz}
|
||||
changeset = %Ecto.Changeset{data: %{}, changes: changes}
|
||||
assert Encoder.encode(changeset) == %{foo: "baz"}
|
||||
end
|
||||
|
||||
test "returns empty map if both data and changes are empty" do
|
||||
changeset = %Ecto.Changeset{data: %{}, changes: %{}}
|
||||
assert Encoder.encode(changeset) == %{}
|
||||
end
|
||||
|
||||
test "returns before/after map if both data and changes are present" do
|
||||
data = %{foo: :bar}
|
||||
changes = %{foo: :baz}
|
||||
changeset = %Ecto.Changeset{data: data, changes: changes}
|
||||
assert Encoder.encode(changeset) == %{before: %{foo: "bar"}, after: %{foo: "baz"}}
|
||||
end
|
||||
|
||||
test "raises if encoder is not derived for a struct" do
|
||||
struct = %{__struct__: Foo, foo: 1, bar: 2}
|
||||
|
||||
assert_raise Protocol.UndefinedError, fn ->
|
||||
Encoder.encode(struct)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Audit.Entry" do
|
||||
test "changeset/2 with valid params and context" do
|
||||
Entry.set_context(%{current_user: %{id: 42}, current_team: %{id: 7}})
|
||||
params = %{entity: "User", entity_id: "42", meta: %{name: "bar"}}
|
||||
cs = Entry.changeset("login", params)
|
||||
assert cs.valid?
|
||||
assert cs.data.name == "login"
|
||||
assert cs.changes.entity == "User"
|
||||
assert cs.changes.entity_id == "42"
|
||||
assert cs.changes.meta == %{name: "bar"}
|
||||
assert cs.changes.user_id == 42
|
||||
assert cs.changes.team_id == 7
|
||||
assert cs.changes.actor_type == :user
|
||||
assert is_struct(cs.changes.datetime, NaiveDateTime)
|
||||
end
|
||||
|
||||
test "changeset/2 missing required fields" do
|
||||
cs = Entry.changeset("test", %{})
|
||||
refute cs.valid?
|
||||
|
||||
assert cs.errors == [
|
||||
entity: {"can't be blank", [validation: :required]},
|
||||
entity_id: {"can't be blank", [validation: :required]}
|
||||
]
|
||||
end
|
||||
|
||||
test "changeset/2 with missing context" do
|
||||
cs = Entry.changeset("test", %{entity: "E", entity_id: "1"})
|
||||
assert is_nil(cs.changes.user_id)
|
||||
assert is_nil(cs.changes.team_id)
|
||||
assert cs.data.user_id == 0
|
||||
assert cs.data.team_id == 0
|
||||
assert cs.data.actor_type == :system
|
||||
end
|
||||
|
||||
test "new/3 with struct and params" do
|
||||
Entry.set_context(%{current_user: %{id: 1}, current_team: %{id: 2}})
|
||||
struct = %TestSchema{id: 123}
|
||||
cs = Entry.new("test", struct, %{meta: %{x: 1}})
|
||||
assert cs.changes.entity == "Plausible.Audit.TestSchema"
|
||||
assert cs.changes.entity_id == "123"
|
||||
assert cs.changes.meta == %{x: 1}
|
||||
end
|
||||
|
||||
test "include_change/2 encodes changeset" do
|
||||
struct = %TestSchema{id: 1, name: "bar"}
|
||||
changeset = Ecto.Changeset.change(struct, name: "baz")
|
||||
|
||||
entry = Entry.new("update", struct)
|
||||
entry = Entry.include_change(entry, changeset)
|
||||
assert entry.changes.change == %{after: %{name: "baz"}, before: %{name: "bar", id: 1}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "Repo integration" do
|
||||
test "update_with_audit/2" do
|
||||
user = new_user() |> Repo.preload([:sso_integration, :sso_domain])
|
||||
|
||||
cs = Plausible.Auth.User.name_changeset(user, %{name: "John Doe"})
|
||||
|
||||
assert {:ok, %Plausible.Auth.User{name: "John Doe"}} =
|
||||
Repo.update_with_audit(cs, "user_update")
|
||||
|
||||
assert [
|
||||
%Plausible.Audit.Entry{
|
||||
name: "user_update",
|
||||
change: %{
|
||||
"after" => %{"name" => "John Doe"},
|
||||
"before" => %{
|
||||
"name" => "Jane Smith"
|
||||
}
|
||||
}
|
||||
}
|
||||
] = Audit.list_entries(entity: "Plausible.Auth.User", entity_id: "#{user.id}")
|
||||
end
|
||||
end
|
||||
|
||||
test "update_with_audit!/2" do
|
||||
user = new_user() |> Repo.preload([:sso_integration, :sso_domain])
|
||||
cs = Plausible.Auth.User.name_changeset(user, %{name: "John Doe"})
|
||||
|
||||
assert %Plausible.Auth.User{name: "John Doe"} =
|
||||
Repo.update_with_audit!(cs, "user_update")
|
||||
|
||||
assert [
|
||||
%Plausible.Audit.Entry{
|
||||
name: "user_update",
|
||||
change: %{
|
||||
"after" => %{"name" => "John Doe"},
|
||||
"before" => %{
|
||||
"name" => "Jane Smith"
|
||||
}
|
||||
}
|
||||
}
|
||||
] = Audit.list_entries(entity: "Plausible.Auth.User", entity_id: "#{user.id}")
|
||||
end
|
||||
|
||||
test "insert_with_audit/2" do
|
||||
changeset =
|
||||
Plausible.Auth.User.new(%{
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
password: "very-secret-and-very-long-123",
|
||||
password_confirmation: "very-secret-and-very-long-123"
|
||||
})
|
||||
|
||||
{:ok, %Plausible.Auth.User{id: user_id, name: "Jane Doe"} = user} =
|
||||
Repo.insert_with_audit(changeset, "user_insert")
|
||||
|
||||
entity_id = to_string(user_id)
|
||||
|
||||
assert [
|
||||
%Plausible.Audit.Entry{
|
||||
name: "user_insert",
|
||||
entity_id: ^entity_id,
|
||||
change: %{
|
||||
"email" => "jane@example.com",
|
||||
"email_verified" => false,
|
||||
"id" => ^user_id,
|
||||
"last_team_identifier" => nil,
|
||||
"name" => "Jane Doe",
|
||||
"previous_email" => nil,
|
||||
"totp_enabled" => false
|
||||
}
|
||||
}
|
||||
] = Audit.list_entries(entity: "Plausible.Auth.User", entity_id: "#{user.id}")
|
||||
end
|
||||
|
||||
test "insert_with_audit!/2" do
|
||||
changeset =
|
||||
Plausible.Auth.User.new(%{
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
password: "very-secret-and-very-long-123",
|
||||
password_confirmation: "very-secret-and-very-long-123"
|
||||
})
|
||||
|
||||
assert %Plausible.Auth.User{name: "Jane Doe", id: user_id} =
|
||||
Repo.insert_with_audit!(changeset, "user_insert")
|
||||
|
||||
assert [
|
||||
%Plausible.Audit.Entry{
|
||||
name: "user_insert",
|
||||
change: %{}
|
||||
}
|
||||
] = Audit.list_entries(entity: "Plausible.Auth.User", entity_id: "#{user_id}")
|
||||
end
|
||||
|
||||
test "delete_with_audit!/2" do
|
||||
user = new_user()
|
||||
user_email = user.email
|
||||
user_id = user.id
|
||||
assert %Plausible.Auth.User{} = Repo.delete_with_audit!(user, "user_delete")
|
||||
|
||||
assert [
|
||||
%Plausible.Audit.Entry{
|
||||
name: "user_delete",
|
||||
change: %{
|
||||
"email" => ^user_email,
|
||||
"email_verified" => true,
|
||||
"id" => ^user_id,
|
||||
"last_team_identifier" => nil,
|
||||
"name" => "Jane Smith",
|
||||
"previous_email" => nil,
|
||||
"totp_enabled" => false
|
||||
}
|
||||
}
|
||||
] = Audit.list_entries(entity: "Plausible.Auth.User", entity_id: "#{user.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -31,6 +31,11 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
|
||||
assert {:ok, sso_domain} = SSO.Domains.add(integration, domain)
|
||||
|
||||
assert audited_entry("sso_domain_added",
|
||||
team_id: integration.team_id,
|
||||
entity_id: "#{sso_domain.id}"
|
||||
)
|
||||
|
||||
assert sso_domain.domain == domain
|
||||
assert is_binary(sso_domain.identifier)
|
||||
refute sso_domain.verified_via
|
||||
|
|
@ -114,6 +119,11 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
|
||||
verified_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
assert audited_entry("sso_domain_verification_success",
|
||||
team_id: integration.team_id,
|
||||
entity_id: "#{verified_domain.id}"
|
||||
)
|
||||
|
||||
assert verified_domain.id == sso_domain.id
|
||||
assert verified_domain.verified_via == :dns_txt
|
||||
assert verified_domain.status == Status.verified()
|
||||
|
|
@ -145,6 +155,11 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
{:ok, _} = SSO.Domains.add(integration, domain)
|
||||
assert {:ok, sso_domain} = SSO.Domains.start_verification(domain)
|
||||
assert sso_domain.status == Status.in_progress()
|
||||
|
||||
assert audited_entry("sso_domain_verification_started",
|
||||
team_id: integration.team_id,
|
||||
entity_id: "#{sso_domain.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "enqueues background work", %{integration: integration} do
|
||||
|
|
@ -170,6 +185,11 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
assert {:ok, sso_domain} = SSO.Domains.start_verification(domain)
|
||||
assert :ok = SSO.Domains.cancel_verification(domain)
|
||||
assert Repo.reload!(sso_domain).status == Status.unverified()
|
||||
|
||||
assert audited_entry("sso_domain_verification_cancelled",
|
||||
team_id: integration.team_id,
|
||||
entity_id: "#{sso_domain.id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert integration.team_id == team.id
|
||||
assert is_binary(integration.identifier)
|
||||
assert %SSO.SAMLConfig{} = integration.config
|
||||
|
||||
assert audited_entry("saml_integration_initiated",
|
||||
team_id: team.id,
|
||||
entity_id: "#{integration.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "does nothing if integration is already initiated" do
|
||||
|
|
@ -84,6 +89,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
assert X509.Certificate.from_pem(integration.config.idp_cert_pem) ==
|
||||
X509.Certificate.from_pem(@cert_pem)
|
||||
|
||||
assert audited_entry("sso_integration_updated",
|
||||
team_id: team.id,
|
||||
entity_id: "#{integration.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "updates integration with whitespace around PEM" do
|
||||
|
|
@ -294,6 +304,8 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert user.email_verified
|
||||
assert user.last_sso_login
|
||||
assert_team_membership(user, team, :viewer)
|
||||
|
||||
assert audited_entry("sso_user_provisioned", team_id: team.id, entity_id: "#{user.id}")
|
||||
end
|
||||
|
||||
test "does not provision a user from identity when identity integration does not match", %{
|
||||
|
|
@ -337,6 +349,8 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert sso_user.sso_domain_id == sso_domain.id
|
||||
assert sso_user.email_verified
|
||||
assert sso_user.last_sso_login
|
||||
|
||||
assert audited_entry("sso_user_provisioned", team_id: team.id, entity_id: "#{user.id}")
|
||||
end
|
||||
|
||||
test "provisions SSO user from existing user with personal team", %{
|
||||
|
|
@ -391,6 +405,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert sso_user.sso_integration_id == integration.id
|
||||
assert sso_user.sso_domain_id == sso_domain.id
|
||||
assert sso_user.last_sso_login
|
||||
|
||||
assert audited_entries(2, "sso_user_provisioned",
|
||||
team_id: team.id,
|
||||
entity_id: "#{sso_user.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "does not provision user without matching setup integration", %{
|
||||
|
|
@ -551,6 +570,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
refute updated_user.sso_identity_id
|
||||
refute updated_user.sso_integration_id
|
||||
refute updated_user.sso_domain_id
|
||||
|
||||
assert audited_entry("sso_user_deprovioned",
|
||||
team_id: team.id,
|
||||
entity_id: "#{updated_user.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "handles standard user gracefully without revoking existing sessions" do
|
||||
|
|
@ -584,6 +608,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
assert team.policy.sso_default_role == :editor
|
||||
assert team.policy.sso_session_timeout_minutes == 600
|
||||
|
||||
assert audited_entry("sso_policy_updated",
|
||||
team_id: team.id,
|
||||
entity_id: "#{team.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "accepts single attributes leaving others as they are" do
|
||||
|
|
@ -746,6 +775,8 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
assert updated_team.id == team.id
|
||||
assert updated_team.policy.force_sso == :all_but_owners
|
||||
|
||||
assert audited_entry("sso_force_mode_changed", team_id: team.id, entity_id: "#{team.id}")
|
||||
end
|
||||
|
||||
test "returns error when conditions not met" do
|
||||
|
|
@ -885,6 +916,11 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert :ok = SSO.remove_integration(integration)
|
||||
refute Repo.reload(integration)
|
||||
refute Repo.reload(sso_domain)
|
||||
|
||||
assert audited_entry("sso_integration_removed",
|
||||
team_id: team.id,
|
||||
entity_id: "#{integration.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "returns error when conditions not met" do
|
||||
|
|
@ -951,8 +987,8 @@ defmodule Plausible.Auth.SSOTest do
|
|||
domain1 = "example-#{Enum.random(1..10_000)}.com"
|
||||
domain2 = "test-#{Enum.random(1..10_000)}.com"
|
||||
|
||||
{:ok, _} = SSO.Domains.add(integration, domain1)
|
||||
{:ok, _} = SSO.Domains.add(integration, domain2)
|
||||
{:ok, d1} = SSO.Domains.add(integration, domain1)
|
||||
{:ok, d2} = SSO.Domains.add(integration, domain2)
|
||||
|
||||
{:ok, _} = SSO.Domains.start_verification(domain1)
|
||||
{:ok, _} = SSO.Domains.start_verification(domain2)
|
||||
|
|
@ -965,6 +1001,21 @@ defmodule Plausible.Auth.SSOTest do
|
|||
refute Repo.reload(integration)
|
||||
refute_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain1})
|
||||
refute_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain2})
|
||||
|
||||
assert audited_entry("sso_domain_verification_cancelled",
|
||||
team_id: team.id,
|
||||
entity_id: "#{d1.id}"
|
||||
)
|
||||
|
||||
assert audited_entry("sso_domain_verification_cancelled",
|
||||
team_id: team.id,
|
||||
entity_id: "#{d2.id}"
|
||||
)
|
||||
|
||||
assert audited_entry("sso_integration_removed",
|
||||
team_id: team.id,
|
||||
entity_id: "#{integration.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "cancels verification jobs when integration is force removed with SSO users" do
|
||||
|
|
@ -979,13 +1030,23 @@ defmodule Plausible.Auth.SSOTest do
|
|||
identity = new_identity("Test User", "test@" <> domain, integration)
|
||||
{:ok, _, _, _} = SSO.provision_user(identity)
|
||||
|
||||
{:ok, _} = SSO.Domains.start_verification(domain)
|
||||
{:ok, sso_domain} = SSO.Domains.start_verification(domain)
|
||||
assert_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain})
|
||||
|
||||
assert :ok = SSO.remove_integration(integration, force_deprovision?: true)
|
||||
|
||||
refute Repo.reload(integration)
|
||||
refute_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain})
|
||||
|
||||
assert audited_entry("sso_domain_verification_cancelled",
|
||||
team_id: team.id,
|
||||
entity_id: "#{sso_domain.id}"
|
||||
)
|
||||
|
||||
assert audited_entry("sso_integration_removed",
|
||||
team_id: team.id,
|
||||
entity_id: "#{integration.id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Plausible.Audit.TestSchema do
|
||||
@moduledoc false
|
||||
use Plausible
|
||||
|
||||
on_ee do
|
||||
use Ecto.Schema
|
||||
|
||||
@derive {Plausible.Audit.Encoder, only: [:id, :name]}
|
||||
|
||||
schema "tests" do
|
||||
field :name, :string
|
||||
end
|
||||
|
||||
defmodule VariantWithAssociation do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
on_ee do
|
||||
@derive {Plausible.Audit.Encoder, only: [:id, :team]}
|
||||
end
|
||||
|
||||
schema "tests" do
|
||||
belongs_to :team, Plausible.Teams.Team
|
||||
end
|
||||
end
|
||||
|
||||
defmodule VariantWithAssociationAllowNotLoaded do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
@derive {Plausible.Audit.Encoder, only: [:id, :team], allow_not_loaded: [:team]}
|
||||
|
||||
schema "tests" do
|
||||
belongs_to :team, Plausible.Teams.Team
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -408,4 +408,30 @@ defmodule Plausible.Teams.Test do
|
|||
|> Plausible.Teams.with_subscription()
|
||||
|> Map.fetch!(:subscription)
|
||||
end
|
||||
|
||||
on_ee do
|
||||
def audited_entry(name, attrs \\ []) do
|
||||
attrs = Keyword.put(attrs, :name, name)
|
||||
|
||||
case Plausible.Audit.list_entries(attrs) do
|
||||
[_] ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
raise "Expected audited entry #{inspect(attrs)} but only found #{inspect(Plausible.Audit.list_entries([]), pretty: true)}."
|
||||
end
|
||||
end
|
||||
|
||||
def audited_entries(length, name, attrs \\ []) do
|
||||
attrs = Keyword.put(attrs, :name, name)
|
||||
|
||||
case Plausible.Audit.list_entries(attrs) do
|
||||
l when is_list(l) and length(l) == length ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
raise "Expected audited entry #{inspect(attrs)} but only found #{inspect(Plausible.Audit.list_entries([]), pretty: true)}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -86,9 +86,19 @@ defmodule Plausible.Auth.SSO.Domain.Verification.WorkerTest do
|
|||
to: [nil: owner2.email],
|
||||
subject: "Your SSO domain #{domain} is ready!"
|
||||
)
|
||||
|
||||
{:ok, domain} = SSO.Domains.get(domain)
|
||||
|
||||
assert audited_entry("sso_domain_verification_success",
|
||||
team_id: team.id,
|
||||
entity_id: "#{domain.id}"
|
||||
)
|
||||
end
|
||||
|
||||
test "domain is marked as unverified when max snoozes exhausted", %{domain: domain} do
|
||||
test "domain is marked as unverified when max snoozes exhausted", %{
|
||||
domain: domain,
|
||||
team: team
|
||||
} do
|
||||
assert {:snooze, _} =
|
||||
perform_job(Worker, %{"domain" => domain},
|
||||
attempt: 14,
|
||||
|
|
@ -102,6 +112,13 @@ defmodule Plausible.Auth.SSO.Domain.Verification.WorkerTest do
|
|||
)
|
||||
|
||||
assert_email_delivered_with(subject: "SSO domain #{domain} verification failure")
|
||||
|
||||
{:ok, domain} = SSO.Domains.get(domain)
|
||||
|
||||
assert audited_entry("sso_domain_verification_failure",
|
||||
team_id: team.id,
|
||||
entity_id: "#{domain.id}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue