diff --git a/.gitignore b/.gitignore index 9517a21..63ff39e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,3 @@ npm-debug.log .env .elixir_ls/ - -# Docker secrets directory (generated by `just init-secrets`) -/secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b4a37..71d9147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CopyToClipboard JavaScript hook with fallback for older browsers - Button shows count of visible selected members (respects search/filter) - German/English translations -- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD) ### Fixed - Email validation false positive when linking user and member with identical emails (#168 Problem #4) diff --git a/Justfile b/Justfile index 25fb35c..856036e 100644 --- a/Justfile +++ b/Justfile @@ -90,27 +90,4 @@ clean: remove-gettext-conflicts: #!/usr/bin/env bash set -euo pipefail - find priv/gettext -type f -exec sed -i '/^<<<<<<>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//' {} \; - -# Production environment commands -# ================================ - -# Initialize secrets directory with generated secrets (only if not exists) -init-prod-secrets: - #!/usr/bin/env bash - set -euo pipefail - if [ -d "secrets" ]; then - echo "Secrets directory already exists. Skipping generation." - exit 0 - fi - echo "Creating secrets directory and generating secrets..." - mkdir -p secrets - mix phx.gen.secret > secrets/secret_key_base.txt - mix phx.gen.secret > secrets/token_signing_secret.txt - openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt - touch secrets/oidc_client_secret.txt - echo "Secrets generated in ./secrets/" - -# Start production environment with Docker Compose -start-prod: init-prod-secrets - docker compose -f docker-compose.prod.yml up -d \ No newline at end of file + find priv/gettext -type f -exec sed -i '/^<<<<<<>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//' {} \; \ No newline at end of file diff --git a/README.md b/README.md index 14435db..6db7980 100644 --- a/README.md +++ b/README.md @@ -217,13 +217,6 @@ For testing the production Docker build locally: # OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback # OIDC_CLIENT_SECRET= - - # Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars): - # SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base - # TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret - # OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret - # DATABASE_URL_FILE=/run/secrets/database_url - # DATABASE_PASSWORD_FILE=/run/secrets/database_password ``` 3. **Start development environment** (for Rauthy): @@ -257,7 +250,7 @@ For actual production deployment: - Set `OIDC_BASE_URL` to your production OIDC provider - Configure proper Docker networks 3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik) -4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets. +4. **Use secure secrets management** (environment variables, Docker secrets, vault) 5. **Configure database backups** diff --git a/config/runtime.exs b/config/runtime.exs index 71138ef..c50356c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,75 +7,6 @@ import Config # any compile-time configuration in here, as it won't be applied. # The block below contains prod specific runtime configuration. -# Helper function to read environment variables with Docker secrets support. -# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from -# that file path. Otherwise falls back to VAR directly. -# VAR_FILE takes priority and must contain the full absolute path to the secret file. -get_env_or_file = fn var_name, default -> - file_var = "#{var_name}_FILE" - - case System.get_env(file_var) do - nil -> - System.get_env(var_name, default) - - file_path -> - case File.read(file_path) do - {:ok, content} -> - String.trim_trailing(content) - - {:error, reason} -> - raise """ - Failed to read secret from file specified in #{file_var}="#{file_path}". - Error: #{inspect(reason)} - """ - end - end -end - -# Same as get_env_or_file but raises if the value is not set -get_env_or_file! = fn var_name, error_message -> - case get_env_or_file.(var_name, nil) do - nil -> raise error_message - value -> value - end -end - -# Build database URL from individual components or use DATABASE_URL directly. -# Supports both approaches: -# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL -# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT -build_database_url = fn -> - case get_env_or_file.("DATABASE_URL", nil) do - nil -> - # Build URL from separate components - host = - System.get_env("DATABASE_HOST") || - raise "DATABASE_HOST is required when DATABASE_URL is not set" - - user = - System.get_env("DATABASE_USER") || - raise "DATABASE_USER is required when DATABASE_URL is not set" - - password = - get_env_or_file!.("DATABASE_PASSWORD", """ - DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set. - """) - - database = - System.get_env("DATABASE_NAME") || - raise "DATABASE_NAME is required when DATABASE_URL is not set" - - port = System.get_env("DATABASE_PORT", "5432") - - # URL-encode the password to handle special characters - encoded_password = URI.encode_www_form(password) - "ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}" - - url -> - url - end -end - # ## Using releases # # If you use `mix release`, you need to explicitly enable the server @@ -90,7 +21,12 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = build_database_url.() + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] @@ -105,12 +41,12 @@ if config_env() == :prod do # want to use a different value for prod and you most likely don't want # to check this value into version control, so we use an environment # variable instead. - # Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets. secret_key_base = - get_env_or_file!.("SECRET_KEY_BASE", """ - environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." port = String.to_integer(System.get_env("PORT") || "4000") @@ -118,47 +54,32 @@ if config_env() == :prod do config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") # Rauthy OIDC configuration - # Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets. - # OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars). - oidc_base_url = System.get_env("OIDC_BASE_URL") - oidc_client_id = System.get_env("OIDC_CLIENT_ID") - oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id) - - client_secret = - if oidc_in_use do - get_env_or_file!.("OIDC_CLIENT_SECRET", """ - environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing. - This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set). - """) - else - get_env_or_file.("OIDC_CLIENT_SECRET", nil) - end - config :mv, :rauthy, - client_id: oidc_client_id || "mv", - base_url: oidc_base_url || "http://localhost:8080/auth/v1", - client_secret: client_secret, + client_id: System.get_env("OIDC_CLIENT_ID") || "mv", + base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1", + client_secret: System.get_env("OIDC_CLIENT_SECRET"), redirect_uri: System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback" # Token signing secret from environment variable # This overrides the placeholder value set in prod.exs - # Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets. token_signing_secret = - get_env_or_file!.("TOKEN_SIGNING_SECRET", """ - environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing. - You can generate one by calling: mix phx.gen.secret - """) + System.get_env("TOKEN_SIGNING_SECRET") || + raise """ + environment variable TOKEN_SIGNING_SECRET is missing. + You can generate one by calling: mix phx.gen.secret + """ config :mv, :token_signing_secret, token_signing_secret config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Bind on all IPv4 interfaces. - # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - ip: {0, 0, 0, 0}, + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, port: port ], secret_key_base: secret_key_base, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b4b7a1f..0bb2840 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,32 +2,21 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - ports: - - "4001:4001" + # Use host network for local testing to access localhost:8080 (Rauthy) + # In real production, remove this and use external OIDC provider + network_mode: host environment: - # Database configuration using separate variables - # Use Docker service name for internal networking - DATABASE_HOST: "db-prod" - DATABASE_PORT: "5432" - DATABASE_USER: "postgres" - DATABASE_NAME: "mv_prod" - DATABASE_PASSWORD_FILE: "/run/secrets/db_password" - # Phoenix secrets via Docker secrets - SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base" - TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret" - PHX_HOST: "${PHX_HOST:-localhost}" + DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod" + SECRET_KEY_BASE: "${SECRET_KEY_BASE}" + TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}" + PHX_HOST: "${PHX_HOST}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - use host.docker.internal to reach host services + # Rauthy OIDC config - uses localhost because of host network mode OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" - OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret" + OIDC_BASE_URL: "http://localhost:8080/auth/v1" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback" - secrets: - - db_password - - secret_key_base - - token_signing_secret - - oidc_client_secret depends_on: - db-prod restart: unless-stopped @@ -37,25 +26,13 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD_FILE: /run/secrets/db_password + POSTGRES_PASSWORD: postgres POSTGRES_DB: mv_prod - secrets: - - db_password volumes: - postgres_data_prod:/var/lib/postgresql/data ports: - "5001:5432" restart: unless-stopped -secrets: - db_password: - file: ./secrets/db_password.txt - secret_key_base: - file: ./secrets/secret_key_base.txt - token_signing_secret: - file: ./secrets/token_signing_secret.txt - oidc_client_secret: - file: ./secrets/oidc_client_secret.txt - volumes: postgres_data_prod: diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 08133b5..54a5a64 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do

{msg}

-
@@ -368,63 +368,61 @@ defmodule MvWeb.CoreComponents do end ~H""" -
- - - - - - - - - - - - + + + +
{col[:label]} - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} - field={"custom_field_#{dyn_col[:custom_field].id}"} - label={dyn_col[:custom_field].name} - sort_field={@sort_field} - sort_order={@sort_order} - /> - - {gettext("Actions")} -
- {render_slot(col, @row_item.(row))} - - {if dyn_col[:render] do - rendered = dyn_col[:render].(@row_item.(row)) + + + + + + + + + + + + - - - -
{col[:label]} + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + + {gettext("Actions")} +
+ {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) - if rendered == "" do - "" - else - rendered - end - else + if rendered == "" do "" - end} - -
- <%= for action <- @action do %> - {render_slot(action, @row_item.(row))} - <% end %> -
-
- + else + rendered + end + else + "" + end} +
+
+ <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> +
+
""" end diff --git a/lib/mv_web/helpers/date_formatter.ex b/lib/mv_web/helpers/date_formatter.ex deleted file mode 100644 index eaa9271..0000000 --- a/lib/mv_web/helpers/date_formatter.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule MvWeb.Helpers.DateFormatter do - @moduledoc """ - Centralized date formatting helper for the application. - Formats dates in European format (dd.mm.yyyy). - """ - - use Gettext, backend: MvWeb.Gettext - - @doc """ - Formats a Date struct to European format (dd.mm.yyyy). - - ## Examples - - iex> MvWeb.Helpers.DateFormatter.format_date(~D[2024-03-15]) - "15.03.2024" - - iex> MvWeb.Helpers.DateFormatter.format_date(nil) - "" - """ - def format_date(%Date{} = date) do - Calendar.strftime(date, "%d.%m.%Y") - end - - def format_date(nil), do: "" - - def format_date(_), do: "Invalid date" -end diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex index 3817d90..b847308 100644 --- a/lib/mv_web/live/components/sort_header_component.ex +++ b/lib/mv_web/live/components/sort_header_component.ex @@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do @impl true def render(assigns) do ~H""" -
+