From a30dc6652067289935eeecbb66e013a1a00eba45 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 19 Feb 2026 10:15:22 +0100 Subject: [PATCH 1/4] docs: improve database grants documentation with SQL example and important notice --- docs/DATABASE.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/DATABASE.md b/docs/DATABASE.md index bc16198..09ad1ce 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -6,11 +6,22 @@ The database management should support different server environments This is the default database manager type. It uses the root user to create and delete databases. This is not recommended for production environments, but it is useful for local development or testing. -### Prerequirements +### Prerequisites + +A database user needs the following grants **on global level (`*.*`)** to dynamically create and delete databases: -You need a database user with the following grants to dynamically create and delete new databases: +```sql +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, + CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, + CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE +ON *.* TO ``@``; +FLUSH PRIVILEGES; +``` -- `SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE` +> [!IMPORTANT] +> The grants must be set on `*.*` (all databases), not on specific databases. +> Otherwise the user cannot create new databases or manage tables within +> dynamically created databases. ### Configuration From acbf11de3ccc0530cec890df65c58eeccecc7563 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 19 Feb 2026 10:15:31 +0100 Subject: [PATCH 2/4] feat: add database grants and connectivity check for Root and Simple mode --- autoload.php | 1 + deployer/requirements/config/set.php | 1 + deployer/requirements/functions.php | 122 +++++++++++++ .../task/check_database_grants.php | 165 ++++++++++++++++++ deployer/requirements/task/list.php | 22 +++ deployer/requirements/task/requirements.php | 1 + 6 files changed, 312 insertions(+) create mode 100644 deployer/requirements/task/check_database_grants.php diff --git a/autoload.php b/autoload.php index b2aa7ba..3ae7a2e 100644 --- a/autoload.php +++ b/autoload.php @@ -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'); diff --git a/deployer/requirements/config/set.php b/deployer/requirements/config/set.php index e113154..8f523d4 100644 --- a/deployer/requirements/config/set.php +++ b/deployer/requirements/config/set.php @@ -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']); diff --git a/deployer/requirements/functions.php b/deployer/requirements/functions.php index 146352c..827e9fd 100644 --- a/deployer/requirements/functions.php +++ b/deployer/requirements/functions.php @@ -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'); @@ -363,3 +384,104 @@ 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] ?? ''; + } + } 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 ($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} + */ +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]; +} diff --git a/deployer/requirements/task/check_database_grants.php b/deployer/requirements/task/check_database_grants.php new file mode 100644 index 0000000..a495dd8 --- /dev/null +++ b/deployer/requirements/task/check_database_grants.php @@ -0,0 +1,165 @@ +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'] + )); + } + } +} diff --git a/deployer/requirements/task/list.php b/deployer/requirements/task/list.php index d4fef27..a27f3ef 100644 --- a/deployer/requirements/task/list.php +++ b/deployer/requirements/task/list.php @@ -37,6 +37,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('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(''); diff --git a/deployer/requirements/task/requirements.php b/deployer/requirements/task/requirements.php index 52eb5d1..20cba39 100644 --- a/deployer/requirements/task/requirements.php +++ b/deployer/requirements/task/requirements.php @@ -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', From 35dc66c2827872e872a37768d0159e0e015b865e Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 19 Feb 2026 10:15:40 +0100 Subject: [PATCH 3/4] docs: add database grants check documentation --- docs/REQUIREMENTS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 835fdc0..2b3655e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -59,6 +59,32 @@ Checks PHP CLI configuration values against expected minimums: Checks for the availability of the `mariadb` or `mysql` client and validates the version against client-specific minimums (MariaDB: >= 10.4.3, MySQL: >= 8.0.17). +### Database grants + +Validates database user permissions based on the configured `database_manager_type`. This check requires the feature deployment autoload to be loaded. + +**Root mode** (`default` / `root`): Connects to the database server and verifies that all required grants are set on global level (`*.*`): + +``` +SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, +CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, +CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE +``` + +`ALL PRIVILEGES ON *.*` is also accepted. + +**Simple mode** (`simple`): Validates pool configuration and tests connectivity to each pool database. + +**Mittwald API** (`mittwald_api`): Skipped (database management is handled via API). + +Credential resolution (no interactive prompt): + +1. Deployer config (`database_user`, `database_password`) +2. Environment variable `DEPLOYER_CONFIG_DATABASE_PASSWORD` +3. Shared `.env` file (TYPO3: `TYPO3_CONF_VARS__DB__Connections__Default__*`, Symfony: `DATABASE_URL`) + +If no credentials can be resolved, the check is skipped. + ### User and permissions Validates that the SSH user belongs to the expected web server group (default: `www-data`) and that the deploy path has the correct owner, group, and permissions (default: `2770`). @@ -133,6 +159,9 @@ set('requirements_env_vars', ['DATABASE_URL', 'APP_SECRET']); set('requirements_check_eol_enabled', true); set('requirements_eol_warn_months', 6); // Warn X months before EOL set('requirements_eol_api_timeout', 5); // API timeout in seconds + +// Database grants check +set('requirements_check_database_grants_enabled', true); ``` ## Extending with custom checks From 06e6196e3fc7e5e36ff6d83de9957038a7441f76 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 19 Feb 2026 11:57:20 +0100 Subject: [PATCH 4/4] fix: resolve host/port from .env for remote database connections Also fix inconsistent "Prerequirements" headings in DATABASE.md and add sql language identifier to REQUIREMENTS.md code block. --- deployer/requirements/functions.php | 24 ++++++++++++++++++++++++ docs/DATABASE.md | 4 ++-- docs/REQUIREMENTS.md | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/deployer/requirements/functions.php b/deployer/requirements/functions.php index 7b48d4f..65b10eb 100644 --- a/deployer/requirements/functions.php +++ b/deployer/requirements/functions.php @@ -434,6 +434,14 @@ function resolveDatabaseCredentials(): ?array : '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'] ?? ''; @@ -447,6 +455,22 @@ function resolveDatabaseCredentials(): ?array $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; + } + } } } } diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 09ad1ce..bdd408a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -33,7 +33,7 @@ set('database_manager_type', 'root'); This database manager type uses a simple configuration file to manage the databases. It can be used in environments where a privileged database user is not available. Therefor it is not possible to create and delete databases dynamically. So a pool of existing databases must be provided, which can be used for the deployment. The database manager will use the first available database from the pool for a new feature instance. -### Prerequirements +### Prerequisites An number of configured databases in the pool, e.g. 10-20 databases. @@ -63,7 +63,7 @@ This database manager type uses the [Mittwald API](https://developer.mittwald.de > [!NOTE] > The Mittwald API client is an optional dependency. Install it with `composer require mittwald/api-client`. -### Prerequirements +### Prerequisites - A Mittwald project with API access - An API token with database management permissions diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 2b3655e..bcd34d7 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -65,7 +65,7 @@ Validates database user permissions based on the configured `database_manager_ty **Root mode** (`default` / `root`): Connects to the database server and verifies that all required grants are set on global level (`*.*`): -``` +```sql SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE