Skip to content
1 change: 1 addition & 0 deletions autoload.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
require_once(__DIR__ . '/deployer/requirements/task/check_php_extensions.php');
require_once(__DIR__ . '/deployer/requirements/task/check_php_settings.php');
require_once(__DIR__ . '/deployer/requirements/task/check_database.php');
require_once(__DIR__ . '/deployer/requirements/task/check_database_grants.php');
require_once(__DIR__ . '/deployer/requirements/task/check_user.php');
require_once(__DIR__ . '/deployer/requirements/task/check_env.php');
require_once(__DIR__ . '/deployer/requirements/task/check_eol.php');
Expand Down
1 change: 1 addition & 0 deletions deployer/requirements/config/set.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
set('requirements_check_user_enabled', true);
set('requirements_check_env_enabled', true);
set('requirements_check_eol_enabled', true);
set('requirements_check_database_grants_enabled', true);

// Locales
set('requirements_locales', ['de_DE.utf8', 'en_US.utf8']);
Expand Down
146 changes: 146 additions & 0 deletions deployer/requirements/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@
'pcre.jit', // Treated as numeric (>= 1) to ensure enabled
];

/**
* Required MySQL/MariaDB grants on global level (*.*) for Root mode.
*/
const REQUIREMENT_DATABASE_GRANTS = [
'SELECT',
'INSERT',
'UPDATE',
'DELETE',
'CREATE',
'DROP',
'INDEX',
'ALTER',
'CREATE TEMPORARY TABLES',
'LOCK TABLES',
'EXECUTE',
'CREATE VIEW',
'SHOW VIEW',
'CREATE ROUTINE',
'ALTER ROUTINE',
];

function addRequirementRow(string $check, string $status, string $info = ''): void
{
$rows = get('requirements_rows');
Expand Down Expand Up @@ -373,3 +394,128 @@ function getSharedEnvVars(): array

return $vars;
}

/**
* Resolve database credentials without triggering an interactive prompt.
*
* Resolution chain:
* 1. Deployer config (database_user, database_password)
* 2. Environment variable DEPLOYER_CONFIG_DATABASE_PASSWORD
* 3. Shared .env file (TYPO3/Symfony-specific parsing)
*
* @return array{user: string, password: string, host: string, port: int}|null
*/
function resolveDatabaseCredentials(): ?array
{
$user = has('database_user') ? (string) get('database_user') : '';
$password = has('database_password') ? (string) get('database_password') : '';
$host = has('database_host') ? (string) get('database_host') : '127.0.0.1';
$port = has('database_port') ? (int) get('database_port') : 3306;

if ($password === '') {
$envPassword = getenv('DEPLOYER_CONFIG_DATABASE_PASSWORD');

if (is_string($envPassword) && $envPassword !== '') {
$password = $envPassword;
}
}

if ($user === '' || $password === '') {
$envVars = getSharedEnvVars();

if (has('app_type') && get('app_type') === 'typo3') {
if ($user === '') {
$user = $envVars['TYPO3_CONF_VARS__DB__Connections__Default__user'] ?? '';
}

if ($password === '') {
$key = has('env_key_database_passwort')
? get('env_key_database_passwort')
: 'TYPO3_CONF_VARS__DB__Connections__Default__password';
$password = $envVars[$key] ?? '';
}

if ($host === '127.0.0.1') {
$envHost = $envVars['TYPO3_CONF_VARS__DB__Connections__Default__host'] ?? '';

if ($envHost !== '') {
$host = $envHost;
}
}
} elseif (has('app_type') && get('app_type') === 'symfony') {
$databaseUrl = $envVars['DATABASE_URL'] ?? '';

if ($databaseUrl !== '') {
if ($user === '') {
$parsed = parse_url($databaseUrl, PHP_URL_USER);
$user = is_string($parsed) ? urldecode($parsed) : '';
}

if ($password === '') {
$parsed = parse_url($databaseUrl, PHP_URL_PASS);
$password = is_string($parsed) ? urldecode($parsed) : '';
}

if ($host === '127.0.0.1') {
$parsed = parse_url($databaseUrl, PHP_URL_HOST);

if (is_string($parsed) && $parsed !== '') {
$host = $parsed;
}
}

if ($port === 3306) {
$parsed = parse_url($databaseUrl, PHP_URL_PORT);

if (is_int($parsed)) {
$port = $parsed;
}
}
}
}
}
Comment on lines +423 to +476
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

$host stays 127.0.0.1 when both credentials are pre-resolved before reaching the .env block.

The guard at line 423 ($user === '' || $password === '') prevents .env from being read when both credentials are already satisfied — for example, database_user set in the Deployer config and database_password supplied via DEPLOYER_CONFIG_DATABASE_PASSWORD. In that case getSharedEnvVars() is never called, so the TYPO3/Symfony host resolution on lines 438–464 is never reached and $host remains 127.0.0.1. For projects where the DB host is only in the shared .env (remote RDS/MariaDB endpoint), the grants check will silently attempt to connect to the wrong host.

The TYPO3/Symfony host-from-env logic added inside the block already handles the case where at least one credential is missing (addressing the earlier review). The remaining gap is when credentials are fully pre-resolved but database_host is not explicitly configured.

🛠️ Proposed fix

Decouple host/port resolution from the credential guard by checking whether defaults are still in place independently:

+    $needsHostFromEnv = !has('database_host') || $host === '127.0.0.1';
+    $needsPortFromEnv = !has('database_port') || $port === 3306;
+
-    if ($user === '' || $password === '') {
+    if ($user === '' || $password === '' || $needsHostFromEnv || $needsPortFromEnv) {
         $envVars = getSharedEnvVars();
 
         if (has('app_type') && get('app_type') === 'typo3') {
             if ($user === '') {
                 $user = $envVars['TYPO3_CONF_VARS__DB__Connections__Default__user'] ?? '';
             }
 
             if ($password === '') {
                 $key = has('env_key_database_passwort')
                     ? get('env_key_database_passwort')
                     : 'TYPO3_CONF_VARS__DB__Connections__Default__password';
                 $password = $envVars[$key] ?? '';
             }
 
-            if ($host === '127.0.0.1') {
+            if ($needsHostFromEnv) {
                 $envHost = $envVars['TYPO3_CONF_VARS__DB__Connections__Default__host'] ?? '';
 
                 if ($envHost !== '') {
                     $host = $envHost;
                 }
             }
         } elseif (has('app_type') && get('app_type') === 'symfony') {
             $databaseUrl = $envVars['DATABASE_URL'] ?? '';
 
             if ($databaseUrl !== '') {
                 if ($user === '') {
                     $parsed = parse_url($databaseUrl, PHP_URL_USER);
                     $user = is_string($parsed) ? urldecode($parsed) : '';
                 }
 
                 if ($password === '') {
                     $parsed = parse_url($databaseUrl, PHP_URL_PASS);
                     $password = is_string($parsed) ? urldecode($parsed) : '';
                 }
 
-                if ($host === '127.0.0.1') {
+                if ($needsHostFromEnv) {
                     $parsed = parse_url($databaseUrl, PHP_URL_HOST);
 
                     if (is_string($parsed) && $parsed !== '') {
                         $host = $parsed;
                     }
                 }
 
-                if ($port === 3306) {
+                if ($needsPortFromEnv) {
                     $parsed = parse_url($databaseUrl, PHP_URL_PORT);
 
                     if (is_int($parsed)) {
                         $port = $parsed;
                     }
                 }
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@deployer/requirements/functions.php` around lines 423 - 476, The credential
guard (if ($user === '' || $password === '')) prevents reading shared env vars
so host/port resolution for typ o3/symfony never runs when credentials are
pre-resolved; change the logic so host/port are resolved independently: call
getSharedEnvVars() when $host === '127.0.0.1' or $port === 3306 (or when
$host/$port are defaults) and apply the TYPO3
(TYPO3_CONF_VARS__DB__Connections__Default__host) and Symfony (DATABASE_URL
parse_url PHP_URL_HOST / PHP_URL_PORT) resolution outside or in addition to the
existing ($user/$password) block, reusing the same $envVars and keeping the
existing keys (env_key_database_passwort, DATABASE_URL) and parse_url usage.


if ($user === '' || $password === '') {
return null;
}

return [
'user' => $user,
'password' => $password,
'host' => $host,
'port' => $port,
];
}

/**
* Parse SHOW GRANTS output and check required grants on global level (*.*).
*
* @param string $grantsOutput Raw output from SHOW GRANTS FOR CURRENT_USER()
* @return array{ok: bool, missing: list<string>}
*/
function parseGlobalGrants(string $grantsOutput): array
{
$globalGrants = [];

foreach (explode("\n", $grantsOutput) as $line) {
$line = trim($line);

if (!preg_match('/^GRANT\s+(.+?)\s+ON\s+\*\.\*\s+TO\s+/i', $line, $matches)) {
continue;
}

$grantsStr = strtoupper(trim($matches[1]));

if ($grantsStr === 'ALL PRIVILEGES' || $grantsStr === 'ALL') {
return ['ok' => true, 'missing' => []];
}

foreach (explode(', ', $grantsStr) as $grant) {
$globalGrants[] = trim($grant);
}
}

$missing = array_values(array_diff(REQUIREMENT_DATABASE_GRANTS, $globalGrants));

return ['ok' => empty($missing), 'missing' => $missing];
}
165 changes: 165 additions & 0 deletions deployer/requirements/task/check_database_grants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

declare(strict_types=1);

namespace Deployer;

use Deployer\Exception\RunException;

task('requirements:check:database_grants', function (): void {
if (!get('requirements_check_database_grants_enabled')) {
return;
}

$managerType = has('database_manager_type') ? get('database_manager_type') : null;

if ($managerType === null) {
addRequirementRow('Database grants', REQUIREMENT_SKIP, 'No database_manager_type configured');

return;
}

if ($managerType === 'mittwald_api') {
addRequirementRow('Database grants', REQUIREMENT_SKIP, 'Managed via Mittwald API');

return;
}

if ($managerType === 'simple') {
checkSimplePoolConnectivity();

return;
}

// Root / default mode
checkRootGrants();
})->hidden();

function checkRootGrants(): void
{
$credentials = resolveDatabaseCredentials();

if ($credentials === null) {
addRequirementRow(
'Database grants',
REQUIREMENT_SKIP,
'No credentials available (set database_user/database_password or DEPLOYER_CONFIG_DATABASE_PASSWORD)'
);

return;
}

$mysqlBin = has('mysql') ? get('mysql') : 'mysql';
$connectInfo = sprintf('%s@%s:%d', $credentials['user'], $credentials['host'], $credentials['port']);

try {
$output = run(sprintf(
'%s --connect-timeout=5 -u %s -p%s -h %s -P %d -N -e %s 2>&1',
escapeshellarg($mysqlBin),
escapeshellarg($credentials['user']),
"'%secret%'",
escapeshellarg($credentials['host']),
$credentials['port'],
escapeshellarg('SHOW GRANTS FOR CURRENT_USER()')
), secret: $credentials['password']);
} catch (RunException) {
addRequirementRow('Database: connectivity', REQUIREMENT_FAIL, "Cannot connect as $connectInfo");

return;
}

addRequirementRow('Database: connectivity', REQUIREMENT_OK, "Connected as $connectInfo");

$result = parseGlobalGrants($output);

if ($result['ok']) {
addRequirementRow('Database: global grants', REQUIREMENT_OK, 'All required grants on *.*');
} else {
addRequirementRow(
'Database: global grants',
REQUIREMENT_FAIL,
'Missing on *.*: ' . implode(', ', $result['missing'])
);
}
}

function checkSimplePoolConnectivity(): void
{
if (!has('database_pool')) {
addRequirementRow('Database pool', REQUIREMENT_FAIL, 'database_pool not configured');

return;
}

$pool = get('database_pool');

if (empty($pool)) {
addRequirementRow('Database pool', REQUIREMENT_FAIL, 'database_pool is empty');

return;
}

addRequirementRow('Database pool', REQUIREMENT_OK, sprintf('%d database(s) configured', count($pool)));

$mysqlBin = has('mysql') ? get('mysql') : 'mysql';

foreach ($pool as $alias => $config) {
$requiredKeys = ['database_user', 'database_password', 'database_name'];
$missingKeys = array_diff($requiredKeys, array_keys(array_filter($config, 'is_string')));

if (!empty($missingKeys)) {
addRequirementRow(
"Database pool: $alias",
REQUIREMENT_FAIL,
'Missing config: ' . implode(', ', $missingKeys)
);

continue;
}

$host = $config['database_host'] ?? '127.0.0.1';
$port = (int) ($config['database_port'] ?? 3306);
$password = $config['database_password'];

// Resolve env var references (DEPLOYER_CONFIG_* pattern)
if (str_starts_with($password, 'DEPLOYER_CONFIG_')) {
$envValue = getenv($password);
$password = is_string($envValue) && $envValue !== '' ? $envValue : '';
}

if ($password === '') {
addRequirementRow("Database pool: $alias", REQUIREMENT_SKIP, 'Password not resolvable');

continue;
}

try {
run(sprintf(
'%s --connect-timeout=5 -u %s -p%s -h %s -P %d -e %s %s 2>&1',
escapeshellarg($mysqlBin),
escapeshellarg($config['database_user']),
"'%secret%'",
escapeshellarg($host),
$port,
escapeshellarg('SELECT 1'),
escapeshellarg($config['database_name'])
), secret: $password);

addRequirementRow("Database pool: $alias", REQUIREMENT_OK, sprintf(
'%s@%s:%d/%s',
$config['database_user'],
$host,
$port,
$config['database_name']
));
} catch (RunException) {
addRequirementRow("Database pool: $alias", REQUIREMENT_FAIL, sprintf(
'Cannot connect as %s@%s:%d/%s',
$config['database_user'],
$host,
$port,
$config['database_name']
));
}
}
}
22 changes: 22 additions & 0 deletions deployer/requirements/task/list.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@
writeln(sprintf(' MySQL: >= %s', get('requirements_mysql_min_version')));
}

// Database Grants
if (get('requirements_check_database_grants_enabled')) {
$managerType = has('database_manager_type') ? get('database_manager_type') : null;

if ($managerType !== null) {
writeln('');
writeln('<fg=yellow;options=bold>Database Grants</>');

if ($managerType === 'simple') {
$poolSize = has('database_pool') ? count(get('database_pool')) : 0;
writeln(sprintf(' Mode: Simple (pool with %d database(s))', $poolSize));
writeln(' Check: Connectivity per pool entry');
} elseif ($managerType === 'mittwald_api') {
writeln(' Mode: Mittwald API (managed, no grant check)');
} else {
writeln(' Mode: Root');
writeln(' Required grants on *.*:');
writeln(' ' . implode(', ', REQUIREMENT_DATABASE_GRANTS));
}
}
}

// Image Processing
if (get('requirements_check_image_processing_enabled')) {
writeln('');
Expand Down
1 change: 1 addition & 0 deletions deployer/requirements/task/requirements.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'requirements:check:php_extensions',
'requirements:check:php_settings',
'requirements:check:database',
'requirements:check:database_grants',
'requirements:check:user',
'requirements:check:env',
'requirements:check:eol',
Expand Down
Loading