refactor: adress review
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-12 15:29:54 +01:00
parent 4af80a8305
commit 942f2afd9e
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
8 changed files with 108 additions and 62 deletions

View file

@ -56,6 +56,9 @@ defmodule Mv.Membership.Setting do
# Update membership fee settings
{:ok, updated} = Mv.Membership.update_settings(settings, %{include_joining_cycle: false})
"""
# primary_read_warning?: false — We use a custom read prepare that selects only public
# attributes and explicitly excludes smtp_password. Ash warns when the primary read does
# not load all attributes; we intentionally omit the password for security.
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
@ -65,6 +68,8 @@ defmodule Mv.Membership.Setting do
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
alias Ash.Resource.Info, as: ResourceInfo
postgres do
table "settings"
repo Mv.Repo
@ -74,48 +79,25 @@ defmodule Mv.Membership.Setting do
description "Global application settings (singleton resource)"
end
# All public attributes except smtp_password, used to exclude it from default reads.
# This list is used in the read prepare to prevent the sensitive password from being
# returned in standard reads (it can still be read via explicit select in Config).
@public_attributes [
:id,
:club_name,
:member_field_visibility,
:member_field_required,
:include_joining_cycle,
:default_membership_fee_type_id,
:vereinfacht_api_url,
:vereinfacht_api_key,
:vereinfacht_club_id,
:vereinfacht_app_url,
:oidc_client_id,
:oidc_base_url,
:oidc_redirect_uri,
:oidc_client_secret,
:oidc_admin_group_name,
:oidc_groups_claim,
:oidc_only,
:smtp_host,
:smtp_port,
:smtp_username,
:smtp_ssl,
:smtp_from_name,
:smtp_from_email,
:join_form_enabled,
:join_form_field_ids,
:join_form_field_required,
:inserted_at,
:updated_at
]
# Attributes excluded from the default read (sensitive data). Same pattern as smtp_password:
# read only via explicit select when needed; never loaded into default get_settings().
@excluded_from_read [:smtp_password, :oidc_client_secret]
actions do
read :read do
primary? true
# smtp_password is excluded from the default select to prevent it from being returned
# in plaintext via standard reads. Config reads it via an explicit select internally.
# Exclude sensitive attributes (e.g. smtp_password) from default reads. Config reads
# them via explicit select when needed. Uses all attribute names minus excluded so
# the list stays correct when new attributes are added to the resource.
prepare fn query, _context ->
Ash.Query.select(query, @public_attributes)
select_attrs =
__MODULE__
|> ResourceInfo.attribute_names()
|> MapSet.to_list()
|> Kernel.--(@excluded_from_read)
Ash.Query.select(query, select_attrs)
end
end

View file

@ -362,26 +362,41 @@ defmodule Mv.Config do
@doc """
Returns the OIDC client secret.
In production, uses the value from config :mv, :oidc (set by runtime.exs from OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE).
Otherwise ENV OIDC_CLIENT_SECRET, then Settings.
Otherwise ENV OIDC_CLIENT_SECRET, then Settings (read via explicit select; not in default get_settings).
"""
@spec oidc_client_secret() :: String.t() | nil
def oidc_client_secret do
case Application.get_env(:mv, :oidc) do
oidc when is_list(oidc) -> oidc_client_secret_from_config(Keyword.get(oidc, :client_secret))
_ -> env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
_ -> oidc_client_secret_from_env_or_settings()
end
end
@doc """
Returns whether the OIDC client secret is set in Settings (for UI badge). Does not expose the value.
"""
@spec oidc_client_secret_set?() :: boolean()
def oidc_client_secret_set? do
present?(get_oidc_client_secret_from_settings())
end
defp oidc_client_secret_from_config(nil),
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
do: oidc_client_secret_from_env_or_settings()
defp oidc_client_secret_from_config(secret) when is_binary(secret) do
s = String.trim(secret)
if s != "", do: s, else: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
if s != "", do: s, else: oidc_client_secret_from_env_or_settings()
end
defp oidc_client_secret_from_config(_),
do: env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret)
do: oidc_client_secret_from_env_or_settings()
defp oidc_client_secret_from_env_or_settings do
case System.get_env("OIDC_CLIENT_SECRET") do
nil -> get_oidc_client_secret_from_settings()
value -> trim_nil(value)
end
end
@doc """
Returns the OIDC admin group name (for role sync). ENV first, then Settings.
@ -638,4 +653,17 @@ defmodule Mv.Config do
nil
end
end
# Reads the OIDC client secret via explicit select (excluded from default read, same as smtp_password).
defp get_oidc_client_secret_from_settings do
query = Ash.Query.select(Mv.Membership.Setting, [:id, :oidc_client_secret])
case Ash.read_one(query, authorize?: false, domain: Mv.Membership) do
{:ok, settings} when not is_nil(settings) ->
settings |> Map.get(:oidc_client_secret) |> trim_nil()
_ ->
nil
end
end
end

View file

@ -33,6 +33,7 @@ defmodule Mv.Mailer do
require Logger
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
@email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
@doc """
@ -105,6 +106,11 @@ defmodule Mv.Mailer do
password = Mv.Config.smtp_password()
ssl_mode = Mv.Config.smtp_ssl() || "tls"
verify_mode =
if Application.get_env(:mv, :smtp_verify_peer, false),
do: :verify_peer,
else: :verify_none
[
adapter: Swoosh.Adapters.SMTP,
relay: host,
@ -114,10 +120,9 @@ defmodule Mv.Mailer do
auth: :always,
username: username,
password: password,
# OTP 26+ enforces verify_peer; allow self-signed / internal certs.
# tls_options: STARTTLS upgrade (port 587); sockopts: direct SSL connect (port 465).
tls_options: [verify: :verify_none],
sockopts: [verify: :verify_none]
# tls_options: STARTTLS (587); sockopts: direct SSL (465). Verify from :smtp_verify_peer (ENV SMTP_VERIFY_PEER).
tls_options: [verify: verify_mode],
sockopts: [verify: verify_mode]
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
else

View file

@ -54,11 +54,14 @@ defmodule MvWeb.GlobalSettingsLive do
actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor)
environment = Application.get_env(:mv, :environment, :dev)
socket =
socket
|> assign(:page_title, gettext("Settings"))
|> assign(:settings, settings)
|> assign(:locale, locale)
|> assign(:environment, environment)
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
@ -76,7 +79,7 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:oidc_groups_claim_env_set, Mv.Config.oidc_groups_claim_env_set?())
|> assign(:oidc_only_env_set, Mv.Config.oidc_only_env_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:oidc_client_secret_set, present?(settings.oidc_client_secret))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:smtp_env_configured, Mv.Config.smtp_env_configured?())
|> assign(:smtp_host_env_set, Mv.Config.smtp_host_env_set?())
|> assign(:smtp_port_env_set, Mv.Config.smtp_port_env_set?())
@ -274,7 +277,7 @@ defmodule MvWeb.GlobalSettingsLive do
</p>
<% end %>
<%= if Mix.env() == :prod and not @smtp_configured do %>
<%= if @environment == :prod and not @smtp_configured do %>
<div class="mb-4 flex items-start gap-2 p-3 rounded-lg border border-warning bg-warning/10 text-warning-aa text-sm">
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0 mt-0.5" />
<span>
@ -688,13 +691,10 @@ defmodule MvWeb.GlobalSettingsLive do
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
end
# phx-change can fire with only _target (e.g. when focusing a field); avoid FunctionClauseError
def handle_event("validate", params, socket) when is_map(params) do
setting_params =
params["setting"] || Map.get(socket.assigns.form.params || %{}, "setting") || %{}
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
# phx-change can fire without "setting" (e.g. only _target when focusing). Do not validate
# with previous form params to avoid surprising behaviour; wait for the next event with setting data.
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
@impl true
@ -777,7 +777,7 @@ defmodule MvWeb.GlobalSettingsLive do
socket
|> assign(:settings, fresh_settings)
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|> assign(:oidc_client_secret_set, present?(fresh_settings.oidc_client_secret))
|> assign(:oidc_client_secret_set, Mv.Config.oidc_client_secret_set?())
|> assign(:oidc_configured, Mv.Config.oidc_configured?())
|> assign(:smtp_configured, Mv.Config.smtp_configured?())
|> assign(:smtp_password_set, present?(Mv.Config.smtp_password()))