diff --git a/exec/lib/PhpMyAdminSso.php b/exec/lib/PhpMyAdminSso.php index 1870056..09219fb 100644 --- a/exec/lib/PhpMyAdminSso.php +++ b/exec/lib/PhpMyAdminSso.php @@ -39,19 +39,19 @@ final class PhpMyAdminSso } $databaseNames = array_values(array_unique($databaseNames)); - $targetDatabase = ''; + $normalizedRequestedDatabase = null; if ($requestedDatabase !== null) { $requestedDatabase = trim($requestedDatabase); if ($requestedDatabase !== '') { if (!in_array($requestedDatabase, $databaseNames, true)) { throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.'); } - $targetDatabase = $requestedDatabase; + $normalizedRequestedDatabase = $requestedDatabase; } } - if ($targetDatabase === '') { - $targetDatabase = $databaseNames[0]; - } + + $targetDatabase = $normalizedRequestedDatabase ?? $databaseNames[0]; + $restrictToDatabase = $normalizedRequestedDatabase ?? ''; $roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120); $role = $this->generateTemporaryRoleName(); @@ -60,7 +60,7 @@ final class PhpMyAdminSso $this->db->createTemporaryRole($role, $password, $expiresAt); try { - foreach (self::determineGrantTargets($targetDatabase, $databaseNames) as $dbName) { + foreach (self::determineGrantTargets($normalizedRequestedDatabase, $databaseNames) as $dbName) { $this->db->grantTemporaryAdminerAccess($dbName, $role); } } catch (Throwable $e) { @@ -78,7 +78,7 @@ final class PhpMyAdminSso 'port' => $this->db->connectionEndpoint()['port'], 'db' => $targetDatabase, ]; - $ticket = $this->writeLoginTicket($auth, $targetDatabase); + $ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase); return [ 'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket), @@ -90,9 +90,12 @@ final class PhpMyAdminSso * @param string[] $ownedDatabases * @return string[] */ - private static function determineGrantTargets(string $targetDatabase, array $ownedDatabases): array + private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array { - return in_array($targetDatabase, $ownedDatabases, true) ? [$targetDatabase] : []; + if ($requestedDatabase === null || $requestedDatabase === '') { + return $ownedDatabases; + } + return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : []; } private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string @@ -136,7 +139,7 @@ final class PhpMyAdminSso /** * @param array $auth */ - private function writeLoginTicket(array $auth, string $database): string + private function writeLoginTicket(array $auth, string $database, string $restrictToDatabase): string { $ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets'; if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) { @@ -153,6 +156,7 @@ final class PhpMyAdminSso 'auth' => $auth, 'route' => '/database/structure', 'db' => $database, + 'restrict_db' => $restrictToDatabase, ]; $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); if (!is_string($encoded) || $encoded === '') { diff --git a/tests/phpmyadmin_sso_test.php b/tests/phpmyadmin_sso_test.php index d10b631..31525ee 100644 --- a/tests/phpmyadmin_sso_test.php +++ b/tests/phpmyadmin_sso_test.php @@ -62,6 +62,7 @@ assert_true($mysqlServiceClass->hasConstant('TMP_ROLE_HOST'), 'MySQLService must assert_same('127.0.0.1', $mysqlServiceClass->getConstant('TMP_ROLE_HOST'), 'TMP_ROLE_HOST must scope the temporary SSO role to the phpMyAdmin app host'); // --- phpMyAdmin SSO must only grant access to the database being logged into, not every database the account owns --- +// --- unless no specific database was requested at all (the top-nav PHPMYADMIN button), which must grant everything --- $ssoClassForGrants = new ReflectionClass(PhpMyAdminSso::class); assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method'); @@ -71,12 +72,22 @@ $determineGrantTargets->setAccessible(true); assert_same( ['demo_target'], $determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']), - 'issuing a ticket for one database must only grant that database, not every database the user owns' + 'requesting one database must only grant that database, not every database the user owns' ); assert_same( [], $determineGrantTargets->invoke(null, 'not_owned', ['demo_target', 'demo_other']), - 'a target database that is not in the owned list must never be granted' + 'a requested database that is not in the owned list must never be granted' +); +assert_same( + ['demo_target', 'demo_other'], + $determineGrantTargets->invoke(null, null, ['demo_target', 'demo_other']), + 'no database requested at all (top-nav PHPMYADMIN button) must grant every owned database' +); +assert_same( + ['demo_target', 'demo_other'], + $determineGrantTargets->invoke(null, '', ['demo_target', 'demo_other']), + 'an empty-string requested database must also grant every owned database' ); // --- phpMyAdmin login ticket files must not be world-readable --- @@ -102,7 +113,7 @@ $auth = [ 'port' => 33033, 'db' => 'demo_db', ]; -$token = $writeTicket->invoke($sso, $auth, 'demo_db'); +$token = $writeTicket->invoke($sso, $auth, 'demo_db', 'demo_db'); assert_true(preg_match('/\A[a-f0-9]{64}\z/', $token) === 1, 'ticket token must be a 64 char lowercase hex string'); @@ -114,9 +125,15 @@ assert_same(0640, $mode, 'ticket files contain a plaintext MySQL password and mu $decoded = json_decode((string)file_get_contents($ticketPath), true); assert_true(is_array($decoded), 'ticket file must contain valid JSON'); -assert_same('demo_db', $decoded['db'] ?? null, 'ticket must record the target database'); +assert_same('demo_db', $decoded['db'] ?? null, 'ticket must record the target/landing database'); +assert_same('demo_db', $decoded['restrict_db'] ?? null, 'a single-database login must record that database as the grant restriction'); assert_same('da_tmp_phpmyadmin_demo_test', $decoded['auth']['user'] ?? null, 'ticket must record the temporary role name'); +$allDbToken = $writeTicket->invoke($sso, $auth, 'demo_db', ''); +$allDbTicketPath = $ssoDir . '/tickets/' . $allDbToken . '.json'; +$allDbDecoded = json_decode((string)file_get_contents($allDbTicketPath), true); +assert_same('', $allDbDecoded['restrict_db'] ?? null, 'an all-databases login must record an empty restrict_db (no restriction)'); + // cleanup array_map('unlink', glob($ssoDir . '/tickets/*') ?: []); @rmdir($ssoDir . '/tickets');