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:
Adam Rutkowski 2025-07-22 12:53:24 +02:00 committed by GitHub
parent ca2611fc83
commit adf39ca7a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 986 additions and 28 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

133
lib/plausible/audit/repo.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"},

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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