diff --git a/migrations/container/20260930120000_site-command_restrict_global_db_grants.php b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php new file mode 100644 index 00000000..46be1acd --- /dev/null +++ b/migrations/container/20260930120000_site-command_restrict_global_db_grants.php @@ -0,0 +1,396 @@ +is_first_execution ) { + EE::debug( 'Skipping restrict-global-db-grants migration on a fresh install.' ); + + return; + } + + $global_sites = array_filter( + Site::all( [ 'site_url', 'db_host', 'db_name', 'db_user' ] ), + function ( $site ) { + return GLOBAL_DB === $site->db_host && ! empty( $site->db_user ) && ! empty( $site->db_name ); + } + ); + // A name without pattern characters already matches only itself. + $sites = array_values( + array_filter( + $global_sites, + function ( $site ) { + return false !== strpbrk( $site->db_name, '\\_%' ); + } + ) + ); + + if ( empty( $sites ) ) { + EE::debug( 'Skipping restrict-global-db-grants migration: no global-db site needs it.' ); + + return; + } + + if ( 'running' !== \EE_DOCKER::container_status( GLOBAL_DB_CONTAINER ) ) { + $this->warn_manual( $sites, $global_sites, 'the global database is not running' ); + + return; + } + + wait_for_global_db(); + + $columns = $this->get_privilege_columns(); + $state = $columns ? $this->read_state( $sites, $columns ) : false; + if ( false === $state ) { + $this->warn_manual( $sites, $global_sites, 'their grants could not be read' ); + + return; + } + + $restricted = 0; + foreach ( $sites as $i => $site ) { + if ( $this->restrict_grant( $site, $state[ $i ], $global_sites ) ) { + $restricted++; + } + } + + if ( $restricted ) { + EE::log( sprintf( 'Restricted the database grant of %d site(s) to their own database.', $restricted ) ); + } + } + + /** + * Privilege columns of `mysql.db`, which differ between MariaDB versions. + * + * @return string[] + */ + private function get_privilege_columns() { + + $result = run_global_db_sql( "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'mysql' AND TABLE_NAME = 'db' AND COLUMN_NAME LIKE '%\\\\_priv' ORDER BY ORDINAL_POSITION;" ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); + + return []; + } + $columns = preg_split( '/\s+/', trim( $result->stdout ), -1, PREG_SPLIT_NO_EMPTY ); + if ( ! in_array( 'Grant_priv', $columns, true ) || preg_grep( '/^\w+_priv$/', $columns, PREG_GREP_INVERT ) ) { + return []; + } + + return $columns; + } + + /** + * Reads what every site needs in one round trip: its users, grant rows and the other databases its old grants match. + * + * @param Site[] $sites Sites to check. + * @param string[] $columns Privilege columns of `mysql.db`. + * + * @return array|false Per site: users, hosts, grants (name => whether it is the grant EasyEngine creates), matches (old name => database names). + */ + private function read_state( array $sites, array $columns ) { + + // The GRANT ALL row EasyEngine creates: every privilege except GRANT OPTION; newer columns may be either. + $checks = []; + foreach ( $columns as $column ) { + if ( 'Grant_priv' === $column ) { + $checks[] = "`Grant_priv` = 'N'"; + } elseif ( in_array( $column, self::BASE_PRIVILEGES, true ) ) { + $checks[] = sprintf( "%s = 'Y'", sql_quote_identifier( $column ) ); + } + } + $is_ee_grant = implode( ' AND ', $checks ); + + $sql = ''; + $state = []; + foreach ( $sites as $i => $site ) { + $user = sql_quote_string( $site->db_user ); + $names = array_merge( [ $this->get_grant_name( $site->db_name ) ], $this->get_stale_grant_names( $site->db_name ) ); + // HEX(): `mysql -B` escapes backslashes in its output. + $sql .= sprintf( "SELECT 'user', %d, COUNT(*), COUNT(IF(Host = '%%', 1, NULL)) FROM mysql.user WHERE User = %s;\n", $i, $user ); + $sql .= sprintf( + "SELECT 'grant', %d, HEX(Db), IF(%s, 1, 0) FROM mysql.db WHERE User = %s AND Host = '%%' AND Db IN (%s);\n", + $i, + $is_ee_grant, + $user, + implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_string', $names ) ) + ); + // BINARY LIKE matches databases the way a GRANT pattern does (case-sensitive, byte-wise `_`). + foreach ( $this->get_stale_grant_names( $site->db_name ) as $name ) { + $sql .= sprintf( + "SELECT 'match', %d, HEX(%s), HEX(SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE BINARY SCHEMA_NAME LIKE %s AND SCHEMA_NAME <> %s;\n", + $i, + sql_quote_string( $name ), + sql_quote_string( $name ), + sql_quote_string( $site->db_name ) + ); + } + $state[ $i ] = [ + 'users' => 0, + 'hosts' => 0, + 'grants' => [], + 'matches' => [], + ]; + } + + $result = run_global_db_sql( $sql ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); + + return false; + } + + foreach ( explode( "\n", trim( $result->stdout ) ) as $line ) { + $row = explode( "\t", $line ); + if ( 4 !== count( $row ) || ! isset( $state[ $row[1] ] ) ) { + continue; + } + $i = (int) $row[1]; + if ( 'user' === $row[0] ) { + $state[ $i ]['users'] = (int) $row[2]; + $state[ $i ]['hosts'] = (int) $row[3]; + } elseif ( 'grant' === $row[0] ) { + $state[ $i ]['grants'][ hex2bin( $row[2] ) ] = '1' === $row[3]; + } elseif ( 'match' === $row[0] ) { + $state[ $i ]['matches'][ hex2bin( $row[2] ) ][] = hex2bin( $row[3] ); + } + } + + return $state; + } + + /** + * Replaces the stale grants of the site's user with the exact one, where that is safe. + * + * @param Site $site Site row. + * @param array $state What read_state() found for it. + * @param Site[] $global_sites All global-db site rows. + * + * @return bool Whether the grant was changed. + */ + private function restrict_grant( $site, array $state, array $global_sites ) { + + $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); + + if ( ! $state['users'] ) { + EE::debug( sprintf( 'Site %s: database user %s does not exist, skipping.', $site->site_url, $site->db_user ) ); + + return false; + } + if ( ! $state['hosts'] ) { + EE::warning( sprintf( "Did not restrict the database grant of site %s: its database user %s exists only for hosts other than '%%'. Please check its grants with SHOW GRANTS.", $site->site_url, $site->db_user ) ); + + return false; + } + + $current = $this->get_grant_name( $site->db_name ); + $exact = db_name_fits_exact_grant( $site->db_name ); + $stale = array_values( array_intersect( $this->get_stale_grant_names( $site->db_name ), array_keys( $state['grants'] ) ) ); + + if ( empty( $stale ) ) { + if ( ! $exact && isset( $state['grants'][ $current ] ) ) { + $this->warn_not_exact( $site ); + } else { + EE::debug( sprintf( 'Site %s: database grant is already exact.', $site->site_url ) ); + } + + return false; + } + + $custom = array_keys( array_diff( $state['grants'], [ true ] ) ); + if ( $custom ) { + $steps = ''; + foreach ( $stale as $name ) { + $steps .= sprintf( "REVOKE ALL PRIVILEGES ON %1\$s.* FROM %2\$s; -- and, if it holds GRANT OPTION: REVOKE GRANT OPTION ON %1\$s.* FROM %2\$s;\n", sql_quote_identifier( $name ), $user ); + } + EE::warning( + sprintf( + "Did not restrict the database grant of site %s: the grants of database user %s on %s differ from the ALL PRIVILEGES grant EasyEngine creates. To restrict them by hand, grant the privileges the site needs on %s, then run as root on the global database:\n%sFLUSH PRIVILEGES;", + $site->site_url, + $site->db_user, + implode( ', ', array_map( 'EE\\Site\\Utils\\sql_quote_identifier', $custom ) ), + sql_quote_grant_database( $site->db_name ), + $steps + ) + ); + + return false; + } + + // Another site of the same user may reach its database only through this grant. + $others = []; + foreach ( $global_sites as $other ) { + if ( $other->db_user === $site->db_user && $other->site_url !== $site->site_url ) { + $others[ $other->db_name ] = $other->site_url; + } + } + $revoke = []; + $kept = []; + foreach ( $stale as $name ) { + $matches = isset( $state['matches'][ $name ] ) ? $state['matches'][ $name ] : []; + $shared = array_intersect_key( $others, array_flip( $matches ) ); + if ( $shared ) { + $kept[ $name ] = $shared; + } else { + $revoke[] = $name; + } + } + + // Nothing to change when the only stale grants are kept and the current one is there already. + if ( $revoke || ! isset( $state['grants'][ $current ] ) ) { + $sql = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); + foreach ( $revoke as $name ) { + $sql .= sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $name ), $user ); + } + // One batch: it stops at the first error, so nothing is revoked unless the new GRANT worked. + $result = run_global_db_sql( $sql . 'FLUSH PRIVILEGES;' ); + if ( ! $result || 0 !== $result->return_code ) { + $this->debug_result( $result ); + EE::warning( sprintf( "Could not restrict the database grant of site %s to its own database. Please run this as root on the global database:\n%sFLUSH PRIVILEGES;", $site->site_url, $sql ) ); + + return false; + } + } + + foreach ( $kept as $name => $shared ) { + EE::warning( + sprintf( + 'Kept the database grant of site %1$s on %2$s: it also covers the database of site(s) %3$s, which use the same database user %4$s. Once that user has its own grant on each of those databases, run REVOKE ALL PRIVILEGES ON %2$s.* FROM %5$s.', + $site->site_url, + sql_quote_identifier( $name ), + implode( ', ', $shared ), + $site->db_user, + $user + ) + ); + } + if ( ! $exact ) { + $this->warn_not_exact( $site ); + } + EE::debug( sprintf( 'Site %s: database grant restricted to %s.', $site->site_url, $site->db_name ) ); + + return ! empty( $revoke ); + } + + /** + * @param string $db_name Database name. + * + * @return string Database name as the current GRANT writes it. + */ + private function get_grant_name( $db_name ) { + + return str_replace( '``', '`', substr( sql_quote_grant_database( $db_name ), 1, -1 ) ); + } + + /** + * Database names in grants that earlier versions wrote for this database, other than the current grant. + * + * @param string $db_name Database name. + * + * @return string[] + */ + private function get_stale_grant_names( $db_name ) { + + // Unescaped, as granted before the pattern characters were escaped. + $names = [ $db_name ]; + // MariaDB 10.x cut an escaped name longer than 64 characters to 64 when storing it. + if ( preg_match( '/^(.{64})./su', addcslashes( $db_name, '\\_%' ), $match ) ) { + $names[] = $match[1]; + } + + return array_values( array_diff( array_unique( $names ), [ $this->get_grant_name( $db_name ) ] ) ); + } + + /** + * @param Site $site Site row. + */ + private function warn_not_exact( $site ) { + + EE::warning( sprintf( 'The database grant of site %s may still match other database names of the same length as %s: the name is too long for a grant on exactly that database.', $site->site_url, $site->db_name ) ); + } + + /** + * One warning for sites whose grants could not be checked, with SQL for the case the migration would handle. + * + * @param Site[] $sites Sites. + * @param Site[] $global_sites All global-db site rows. + * @param string $reason Why they were not checked. + */ + private function warn_manual( array $sites, array $global_sites, $reason ) { + + $users = []; + foreach ( $global_sites as $other ) { + $users[ $other->db_user ] = isset( $users[ $other->db_user ] ) ? $users[ $other->db_user ] + 1 : 1; + } + $sql = ''; + $shared = ''; + $urls = []; + foreach ( $sites as $site ) { + $urls[] = $site->site_url; + // Only where the new grant differs from the unescaped grant of older versions. + if ( ! in_array( $site->db_name, $this->get_stale_grant_names( $site->db_name ), true ) ) { + continue; + } + $user = sprintf( "%s@'%%'", sql_quote_string( $site->db_user ) ); + $grant = sprintf( "GRANT ALL PRIVILEGES ON %s.* TO %s;\n", sql_quote_grant_database( $site->db_name ), $user ); + if ( $users[ $site->db_user ] > 1 ) { + $shared .= $grant; + } else { + $sql .= $grant . sprintf( "REVOKE ALL PRIVILEGES ON %s.* FROM %s;\n", sql_quote_identifier( $site->db_name ), $user ); + } + } + + $message = sprintf( 'Could not restrict the database grants of site(s) %s to their own database: %s.', implode( ', ', $urls ), $reason ); + if ( '' !== $sql . $shared ) { + $message .= sprintf( " Once it runs, open a root shell with `docker exec -it %s sh -c 'mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\"'` and check each user with SHOW GRANTS. Run these lines one at a time, only for a user whose grant on the old name is still the ALL PRIVILEGES grant EasyEngine created; a REVOKE that fails with \"There is no such grant\" can be skipped:\n%s", GLOBAL_DB_CONTAINER, $sql ); + if ( '' !== $shared ) { + $message .= "These users are shared with another site, so keep their old grant until they have a grant of their own on each site's database:\n" . $shared; + } + $message .= 'FLUSH PRIVILEGES;'; + } + EE::warning( $message ); + } + + /** + * @param bool|object $result Result of run_global_db_sql(). + */ + private function debug_result( $result ) { + + if ( $result ) { + EE::debug( sprintf( 'Global database returned %d: %s', $result->return_code, trim( $result->stderr ) ) ); + } + } + + /** + * Re-granting the pattern would widen access again, so the exact grant stays. + */ + public function down() { + } +} diff --git a/src/helper/site-utils.php b/src/helper/site-utils.php index 3515b11c..672bdd65 100644 --- a/src/helper/site-utils.php +++ b/src/helper/site-utils.php @@ -177,6 +177,86 @@ function sql_quote_identifier( $value ) { return '`' . str_replace( '`', '``', $value ) . '`'; } +/** + * Quotes a database name for a database-level GRANT or REVOKE. + * + * `_`, `%` and `\` are pattern characters there, even inside backticks: unescaped, `a_b` would also match `axb`. + * The name may not exceed 64 characters, so when there is no room to escape them all, each one left over becomes `_`, which still matches it. + * + * @param string $db_name Database name. + * + * @return string + */ +function sql_quote_grant_database( $db_name ) { + + $length = preg_match_all( '/./su', $db_name ); + $spare = 64 - ( false === $length ? strlen( $db_name ) : $length ); + $pattern = preg_replace_callback( + '/[\\\\_%]/', + function ( $match ) use ( &$spare ) { + return $spare-- > 0 ? '\\' . $match[0] : '_'; + }, + $db_name + ); + + return sql_quote_identifier( $pattern ); +} + +/** + * Length of a database name in a GRANT once its pattern characters are escaped. + * + * @param string $db_name Database name. + * + * @return int Characters. + */ +function get_grant_name_length( $db_name ) { + + $length = preg_match_all( '/./su', $db_name ); + + return ( false === $length ? strlen( $db_name ) : $length ) + preg_match_all( '/[\\\\_%]/', $db_name ); +} + +/** + * Whether a GRANT can cover exactly this database: the escaped name has to fit in 64 characters. + * + * @param string $db_name Database name. + * + * @return bool + */ +function db_name_fits_exact_grant( $db_name ) { + + return get_grant_name_length( $db_name ) <= 64; +} + +/** + * Cuts a database name so that, with $suffix appended, its escaped GRANT name fits in 64 characters and the name in 64 bytes. + * + * @param string $db_name Database name. + * @param string $suffix Suffix to append. + * + * @return string + */ +function cut_db_name_for_grant( $db_name, $suffix = '' ) { + + $chars = preg_split( '//u', $db_name, -1, PREG_SPLIT_NO_EMPTY ); + if ( false === $chars ) { + $chars = str_split( $db_name ); + } + $room = 64 - get_grant_name_length( $suffix ); + $bytes = 64 - strlen( $suffix ); + $name = ''; + foreach ( $chars as $char ) { + $room -= false === strpos( '\\_%', $char ) ? 1 : 2; + $bytes -= strlen( $char ); + if ( $room < 0 || $bytes < 0 ) { + break; + } + $name .= $char; + } + + return $name . $suffix; +} + /** * Runs SQL as root on the global database. * @@ -244,7 +324,7 @@ function global_db_has_user( $db_user ) { } /** - * Default database name of a site, cut to MySQL's 64-character limit. + * Default database name of a site, cut so that a GRANT can cover exactly this database. * * @param string $site_url Name of the site. * @@ -252,7 +332,7 @@ function global_db_has_user( $db_user ) { */ function get_default_db_name( $site_url ) { - return substr( str_replace( [ '.', '-' ], '_', $site_url ), 0, 64 ); + return cut_db_name_for_grant( str_replace( [ '.', '-' ], '_', $site_url ) ); } /** @@ -303,7 +383,7 @@ function reserve_global_db_names( $db_name, $db_user, $explicit_db_name ) { for ( $i = 2; $i <= 100; $i++ ) { $suffix = '_' . $i; - $candidate = substr( $db_name, 0, 64 - strlen( $suffix ) ) . $suffix; + $candidate = cut_db_name_for_grant( $db_name, $suffix ); if ( ! $is_taken( $candidate ) ) { EE::log( sprintf( 'Database `%s` already exists, using `%s` instead.', $db_name, $candidate ) ); @@ -352,9 +432,10 @@ function create_user_in_db( $db_host, $db_name = '', $db_user = '', $db_pass = ' return false; } - // `_` and `%` are wildcards in a GRANT's database name: unescaped, `a_b` would also grant on `axb`. - $grant_on = sql_quote_identifier( addcslashes( $db_name, '\\_%' ) ); - if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', $grant_on, $user ) ) ) { + if ( ! db_name_fits_exact_grant( $db_name ) ) { + EE::warning( sprintf( 'Database name `%s` is too long for its grant to cover only this database: user `%s` may also get access to other databases whose names have the same length. A shorter --dbname avoids this.', $db_name, $db_user ) ); + } + if ( ! $ok( sprintf( 'GRANT ALL PRIVILEGES ON %s.* TO %s; FLUSH PRIVILEGES;', sql_quote_grant_database( $db_name ), $user ) ) ) { // Separate calls: a batch stops at its first error. $ok( sprintf( 'DROP DATABASE %s;', $database ) ); $ok( sprintf( 'DROP USER %s;', $user ) );