Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/helm-chart-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ jobs:
echo "::endgroup::"
done

- name: Check database CA mounts and bootstrap configuration
run: |
python3 -m pip install --only-binary :all: PyYAML==6.0.2
python3 scripts/ci/test_database_tls_chart.py

- name: Render every CI fixture
run: |
mkdir -p rendered
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/helm-chart-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- "scripts/ci/helm_chart_release.py"
- "scripts/ci/cleanup-kind-cluster.sh"
- "scripts/ci/test_helm_chart_release.py"
- "scripts/ci/test_database_tls_chart.py"
- "uv.toml"
- ".github/workflows/helm-chart-checks.yml"
- ".github/workflows/helm-chart-validation.yml"
Expand Down
23 changes: 22 additions & 1 deletion .github/workflows/phpunit-feature.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- name: Require central action policy preflight
if: ${{ github.server_url == 'https://github.com' }}
Expand Down Expand Up @@ -313,6 +312,28 @@ jobs:
"dw-server-feature-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}" \
>/dev/null 2>&1 || true

- name: Verify required database TLS and certificate refusals
timeout-minutes: 10
env:
TLS_TEST_IMAGE: durable-workflow-server-phpunit-base
TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
run: |
python3 scripts/ci/test_database_tls_cleanup.py
bash scripts/ci/qualify-database-tls.sh

- name: Clean up database TLS fixtures
if: ${{ always() }}
env:
TLS_TEST_PROJECT: dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
TLS_TEST_ROOT: ${{ runner.temp }}/dw-server-tls-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}
run: |
docker rm -f "$TLS_TEST_PROJECT-certificates" >/dev/null 2>&1 || true
docker ps -aq --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true
docker network ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true
docker volume ls -q --filter "label=com.docker.compose.project=$TLS_TEST_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true
bash scripts/ci/cleanup-database-tls.sh

qualification:
name: Feature source qualification
needs: [preflight, feature]
Expand Down
186 changes: 186 additions & 0 deletions app/Database/DatabaseTlsConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

namespace App\Database;

use PDO;
use Pdo\Mysql;

final class DatabaseTlsConfiguration
{
public static function configure(array $config): array
{
$driver = $config['driver'] ?? null;
if (! in_array($driver, ['pgsql', 'mysql', 'mariadb'], true)) {
return $config;
}

$mode = self::mode($config['sslmode'] ?? null);
$alias = self::mode($config['sslmode_alias'] ?? null);
$requested = self::mode($config['sslmode_requested'] ?? null);
if ($alias !== null && $mode !== null && $alias !== $mode) {
self::invalid('DB_SSLMODE and DB_SSL_MODE request different TLS modes.');
}
if ($requested !== null && $mode !== null && $requested !== $mode) {
self::invalid('The database URL conflicts with DB_SSLMODE.');
}
$mode ??= $alias;
$certificate = self::certificate($config);
$config['sslmode'] = $mode;
$config['sslrootcert'] = $certificate;

if ($mode === null) {
if ($certificate !== null) {
self::invalid('Set DB_SSLMODE=verify-full with DB_SSLROOTCERT.');
}
} else {
$config = self::applyMode($config, $mode, $certificate);
}

return $config;
}

private static function applyMode(array $config, string $mode, ?string $certificate): array
{
$driver = $config['driver'];
$allowed = $driver === 'pgsql'
? ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']
: ['disable', 'require', 'verify-full'];
if (! in_array($mode, $allowed, true)) {
self::invalid('The selected database driver does not support the requested TLS mode. MySQL/MariaDB support disable, require and verify-full.');
}

if (in_array($mode, ['require', 'verify-ca', 'verify-full'], true)) {
self::requireTcp($config);
}
if ($certificate === null && $driver !== 'pgsql' && $mode === 'verify-full') {
$ca = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA;
$certificate = self::certificate(['sslrootcert' => $config['options'][$ca] ?? null]);
$config['sslrootcert'] = $certificate;
}
if (in_array($mode, ['verify-ca', 'verify-full'], true) && $certificate === null) {
self::invalid('Certificate verification requires a readable CA bundle in DB_SSLROOTCERT.');
}

if ($driver === 'pgsql') {
return $config;
}

return self::mysqlOptions($config, $mode, $certificate);
}

public static function snapshot(array $config): array
{
$config = self::configure($config);
$mode = $config['sslmode'] ?? null;
$network = in_array($config['driver'] ?? null, ['pgsql', 'mysql', 'mariadb'], true);
$required = in_array($mode, ['require', 'verify-ca', 'verify-full'], true);
$encrypted = null;
if ($required) {
$encrypted = true;
} elseif ($mode === 'disable') {
$encrypted = false;
}

return [
'mode' => $network ? $mode : null,
'encrypted' => $encrypted,
'ca_verified' => $required ? in_array($mode, ['verify-ca', 'verify-full'], true) : null,
'hostname_verified' => $required ? $mode === 'verify-full' : null,
'evidence' => $required ? 'successful_driver_enforced_connection' : 'not_verified',
'scope' => 'php_to_configured_database_endpoint',
];
}

private static function mode(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (! is_string($value)) {
self::invalid('The database TLS mode must be a string.');
}

return match (strtolower(trim($value))) {
'disabled' => 'disable',
'preferred' => 'prefer',
'required' => 'require',
'verify_ca' => 'verify-ca',
'verify_identity', 'verify-identity' => 'verify-full',
default => strtolower(trim($value)),
};
}

private static function certificate(array $config): ?string
{
$certificate = $config['sslrootcert'] ?? null;
$alias = $config['sslrootcert_alias'] ?? null;
$requested = $config['sslrootcert_requested'] ?? null;
if ($alias !== null && $alias !== '' && $certificate !== null && $certificate !== $alias) {
self::invalid('DB_SSLROOTCERT and DB_SSL_ROOT_CERT name different CA bundles.');
}
$certificate ??= $alias;
if ($requested !== null && $requested !== '' && $certificate !== null && $certificate !== $requested) {
self::invalid('The database URL conflicts with DB_SSLROOTCERT.');
}
if ($certificate === null || $certificate === '') {
return null;
}
if (! is_string($certificate) || strpbrk($certificate, ";\r\n\0") !== false || ! is_file($certificate) || ! is_readable($certificate)) {
self::invalid('The configured database CA bundle must be a readable regular file.');
}

return $certificate;
}

private static function requireTcp(array $config): void
{
$host = $config['host'] ?? '';
if (! is_string($host) || trim($host) === '' || ($config['unix_socket'] ?? '') !== '') {
self::invalid('Required database TLS needs an explicit TCP host, not a Unix socket.');
}
foreach (explode(',', $host) as $endpoint) {
$endpoint = trim($endpoint);
if (str_starts_with($endpoint, '/') || str_starts_with($endpoint, '@') || (in_array($config['driver'], ['mysql', 'mariadb'], true) && strtolower($endpoint) === 'localhost')) {
self::invalid('Required database TLS needs an explicit TCP host, not a Unix socket.');
}
}
}

private static function mysqlOptions(array $config, string $mode, ?string $certificate): array
{
if (! extension_loaded('pdo_mysql') || ! extension_loaded('mysqlnd') || ! extension_loaded('openssl')) {
self::invalid('Explicit MySQL/MariaDB TLS requires PDO MySQL with mysqlnd and OpenSSL.');
}
$ca = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA;
$cipher = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CIPHER : PDO::MYSQL_ATTR_SSL_CIPHER;
$verify = PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT;
$options = $config['options'] ?? [];
$legacyCertificate = $options[$ca] ?? null;
if ($certificate !== null && $legacyCertificate !== null && $certificate !== $legacyCertificate) {
self::invalid('DB_SSLROOTCERT and MYSQL_ATTR_SSL_CA name different CA bundles.');
}
if ($mode === 'disable') {
if ($certificate !== null || $legacyCertificate !== null || isset($options[$cipher])) {
self::invalid('Disabled database TLS conflicts with configured TLS connection options.');
}

return $config;
}

// A nonempty TLS option sets mysqlnd CLIENT_SSL before authentication.
// mysqlnd refuses a server without TLS instead of retrying plaintext.
$options[$cipher] ??= 'DEFAULT';
$options[$verify] = $mode === 'verify-full';
if ($certificate !== null) {
$options[$ca] = $certificate;
}
$config['options'] = $options;

return $config;
}

private static function invalid(string $message): never
{
throw new DatabaseTlsRefused('database_tls_configuration_invalid', $message);
}
}
11 changes: 11 additions & 0 deletions app/Database/DatabaseTlsRefused.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace App\Database;

final class DatabaseTlsRefused extends \RuntimeException
{
public function __construct(public readonly string $reason, string $message, ?\Throwable $previous = null)
{
parent::__construct($reason.': '.$message, 0, $previous);
}
}
15 changes: 14 additions & 1 deletion app/Database/RefusesRustDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

trait RefusesRustDatabase
{
public function connect(array $config)
{
return parent::connect(DatabaseTlsConfiguration::configure($config));
}

public function createConnection($dsn, array $config, array $options)
{
$mysql = in_array($config['driver'], ['mysql', 'mariadb'], true);
Expand All @@ -17,7 +22,15 @@ public function createConnection($dsn, array $config, array $options)
unset($options[$attribute]);
}

$connection = parent::createConnection($dsn, $config, $options);
try {
$connection = parent::createConnection($dsn, $config, $options);
} catch (\PDOException $exception) {
if (in_array($config['sslmode'] ?? null, ['require', 'verify-ca', 'verify-full'], true)) {
throw new DatabaseTlsRefused('database_tls_connection_failed', 'The required database TLS connection could not be established. Check the endpoint, CA bundle, certificate hostname and credentials.', $exception);
}

throw $exception;
}
$errorMode = $connection->getAttribute(PDO::ATTR_ERRMODE);
$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
Expand Down
7 changes: 6 additions & 1 deletion app/Support/ServerReadiness.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use App\Auth\AuthException;
use App\Auth\ConfiguredAuthProvider;
use App\Contracts\AuthProvider;
use App\Database\DatabaseTlsConfiguration;
use App\Database\DatabaseTlsRefused;
use App\Models\WorkflowNamespace;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
Expand Down Expand Up @@ -111,13 +113,16 @@ private function databaseCheck(bool $probeQuery = false): array
'status' => 'ok',
'check_scope' => $probeQuery ? 'bound_read_query' : 'connection_only',
'write_capacity_verified' => false,
'tls' => DatabaseTlsConfiguration::snapshot($connection->getConfig()),
'message' => $probeQuery
? 'The primary database connection can execute a bound read query. Write capacity has not been verified.'
: 'The database connection is available. Write capacity has not been verified.',
];
} catch (\Throwable $exception) {
$reason = $queryAttempted ? 'database_query_failed' : null;
if (BackendUnavailable::is($exception)) {
if ($exception instanceof DatabaseTlsRefused) {
$reason = $exception->reason;
} elseif (BackendUnavailable::is($exception)) {
$reason = 'backend_unavailable';
}

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
},
"extra": {
"durable-workflow": {
"product-train": "2.9.9"
"product-train": "2.9.10"
},
"laravel": {
"dont-discover": []
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
$pgsqlOptions = [$disablePreparesAttribute => true];
}

$databaseTls = [
'sslmode' => env('DB_SSLMODE', env('DB_SSL_MODE')),
'sslmode_requested' => env('DB_SSLMODE'),
'sslmode_alias' => env('DB_SSL_MODE'),
'sslrootcert' => env('DB_SSLROOTCERT', env('DB_SSL_ROOT_CERT')),
'sslrootcert_requested' => env('DB_SSLROOTCERT'),
'sslrootcert_alias' => env('DB_SSL_ROOT_CERT'),
];

return [

/*
Expand Down Expand Up @@ -74,6 +83,7 @@
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
...$databaseTls,
'options' => $mysqlOptions,
],

Expand All @@ -92,6 +102,7 @@
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
...$databaseTls,
'options' => $mysqlOptions,
],

Expand All @@ -107,7 +118,8 @@
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
...$databaseTls,
'sslmode' => $databaseTls['sslmode'] ?? 'prefer',
// Keep native parameter binding without session-scoped statement
// names, so transaction poolers can safely change backend sessions.
'options' => $pgsqlOptions,
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.dedicated-matching.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ name: durable-workflow-server
# daemon reports `shape: dedicated`.

# Generated by scripts/ci/sync-source-release.mjs. Do not edit the fallback.
x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.9}}
x-server-image: &server-image ${DW_SERVER_IMAGE:-durableworkflow/server:${DW_SERVER_TAG:-2.9.10}}

x-server-environment: &server-environment
APP_NAME: "Durable Workflow Server"
APP_ENV: ${APP_ENV:-local}
DW_SERVER_KEY: ${DW_SERVER_KEY:-}
APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.9}}
APP_VERSION: ${APP_VERSION:-${DW_SERVER_TAG:-2.9.10}}
APP_DEBUG: ${APP_DEBUG:-false}
DB_CONNECTION: mysql
DB_HOST: mysql
Expand Down
Loading
Loading