diff --git a/.gitignore b/.gitignore index 63ff39e..9517a21 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ npm-debug.log .env .elixir_ls/ + +# Docker secrets directory (generated by `just init-secrets`) +/secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d9147..28b4a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ 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 a91c0e4..876591d 100644 --- a/Justfile +++ b/Justfile @@ -90,4 +90,27 @@ clean: remove-gettext-conflicts: #!/usr/bin/env bash set -euo pipefail - find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \; \ No newline at end of file + find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \; + +# 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 diff --git a/README.md b/README.md index 6db7980..14435db 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,13 @@ 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): @@ -250,7 +257,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** (environment variables, Docker secrets, vault) +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. 5. **Configure database backups** diff --git a/config/runtime.exs b/config/runtime.exs index c50356c..71138ef 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -7,6 +7,75 @@ 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 @@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do end if config_env() == :prod do - database_url = - System.get_env("DATABASE_URL") || - raise """ - environment variable DATABASE_URL is missing. - For example: ecto://USER:PASS@HOST/DATABASE - """ + database_url = build_database_url.() maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] @@ -41,12 +105,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 = - System.get_env("SECRET_KEY_BASE") || - raise """ - environment variable SECRET_KEY_BASE is missing. - You can generate one by calling: mix phx.gen.secret - """ + 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 + """) host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable." port = String.to_integer(System.get_env("PORT") || "4000") @@ -54,32 +118,47 @@ 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: 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"), + client_id: oidc_client_id || "mv", + base_url: oidc_base_url || "http://localhost:8080/auth/v1", + client_secret: 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 = - System.get_env("TOKEN_SIGNING_SECRET") || - raise """ - environment variable TOKEN_SIGNING_SECRET is missing. - You can generate one by calling: mix phx.gen.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 + """) config :mv, :token_signing_secret, token_signing_secret config :mv, MvWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # Bind on all IPv4 interfaces. + # Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only. # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0}, + ip: {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 0bb2840..b4b7a1f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,21 +2,32 @@ services: app: image: git.local-it.org/local-it/mitgliederverwaltung:latest container_name: mv-prod-app - # Use host network for local testing to access localhost:8080 (Rauthy) - # In real production, remove this and use external OIDC provider - network_mode: host + ports: + - "4001:4001" environment: - 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}" + # 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}" PORT: "4001" PHX_SERVER: "true" - # Rauthy OIDC config - uses localhost because of host network mode + # Rauthy OIDC config - use host.docker.internal to reach host services OIDC_CLIENT_ID: "mv" - OIDC_BASE_URL: "http://localhost:8080/auth/v1" - OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" + OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1" + OIDC_CLIENT_SECRET_FILE: "/run/secrets/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 @@ -26,13 +37,25 @@ services: container_name: mv-prod-db environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_PASSWORD_FILE: /run/secrets/db_password 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 54a5a64..08133b5 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,61 +368,63 @@ 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 - "" + if rendered == "" do + "" + else + rendered + end else - rendered - end - else - "" - end} - -
- <%= for action <- @action do %> - {render_slot(action, @row_item.(row))} - <% end %> -
-
+ "" + 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 new file mode 100644 index 0000000..eaa9271 --- /dev/null +++ b/lib/mv_web/helpers/date_formatter.ex @@ -0,0 +1,27 @@ +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 b847308..3817d90 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""" -
+