Grant all owned databases when phpMyAdmin SSO has no specific target

The top-nav PHPMYADMIN button intentionally omits adminer_db to request
access to every database. determineGrantTargets() and the ticket's new
restrict_db field now distinguish that case from a specific per-row
database request instead of collapsing both into single-database scope.
This commit is contained in:
Marek Miklewicz
2026-07-04 21:29:47 +02:00
parent 95b6d774fc
commit 9a5781e5ed
2 changed files with 35 additions and 14 deletions
+14 -10
View File
@@ -39,19 +39,19 @@ final class PhpMyAdminSso
} }
$databaseNames = array_values(array_unique($databaseNames)); $databaseNames = array_values(array_unique($databaseNames));
$targetDatabase = ''; $normalizedRequestedDatabase = null;
if ($requestedDatabase !== null) { if ($requestedDatabase !== null) {
$requestedDatabase = trim($requestedDatabase); $requestedDatabase = trim($requestedDatabase);
if ($requestedDatabase !== '') { if ($requestedDatabase !== '') {
if (!in_array($requestedDatabase, $databaseNames, true)) { if (!in_array($requestedDatabase, $databaseNames, true)) {
throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.'); 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); $roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120);
$role = $this->generateTemporaryRoleName(); $role = $this->generateTemporaryRoleName();
@@ -60,7 +60,7 @@ final class PhpMyAdminSso
$this->db->createTemporaryRole($role, $password, $expiresAt); $this->db->createTemporaryRole($role, $password, $expiresAt);
try { try {
foreach (self::determineGrantTargets($targetDatabase, $databaseNames) as $dbName) { foreach (self::determineGrantTargets($normalizedRequestedDatabase, $databaseNames) as $dbName) {
$this->db->grantTemporaryAdminerAccess($dbName, $role); $this->db->grantTemporaryAdminerAccess($dbName, $role);
} }
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -78,7 +78,7 @@ final class PhpMyAdminSso
'port' => $this->db->connectionEndpoint()['port'], 'port' => $this->db->connectionEndpoint()['port'],
'db' => $targetDatabase, 'db' => $targetDatabase,
]; ];
$ticket = $this->writeLoginTicket($auth, $targetDatabase); $ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase);
return [ return [
'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket), 'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket),
@@ -90,9 +90,12 @@ final class PhpMyAdminSso
* @param string[] $ownedDatabases * @param string[] $ownedDatabases
* @return string[] * @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 private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string
@@ -136,7 +139,7 @@ final class PhpMyAdminSso
/** /**
* @param array<string,string|int> $auth * @param array<string,string|int> $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'; $ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets';
if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) { if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) {
@@ -153,6 +156,7 @@ final class PhpMyAdminSso
'auth' => $auth, 'auth' => $auth,
'route' => '/database/structure', 'route' => '/database/structure',
'db' => $database, 'db' => $database,
'restrict_db' => $restrictToDatabase,
]; ];
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
if (!is_string($encoded) || $encoded === '') { if (!is_string($encoded) || $encoded === '') {
+21 -4
View File
@@ -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'); 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 --- // --- 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); $ssoClassForGrants = new ReflectionClass(PhpMyAdminSso::class);
assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method'); assert_true($ssoClassForGrants->hasMethod('determineGrantTargets'), 'PhpMyAdminSso must expose a determineGrantTargets() decision method');
@@ -71,12 +72,22 @@ $determineGrantTargets->setAccessible(true);
assert_same( assert_same(
['demo_target'], ['demo_target'],
$determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']), $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( assert_same(
[], [],
$determineGrantTargets->invoke(null, 'not_owned', ['demo_target', 'demo_other']), $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 --- // --- phpMyAdmin login ticket files must not be world-readable ---
@@ -102,7 +113,7 @@ $auth = [
'port' => 33033, 'port' => 33033,
'db' => 'demo_db', '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'); 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); $decoded = json_decode((string)file_get_contents($ticketPath), true);
assert_true(is_array($decoded), 'ticket file must contain valid JSON'); 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'); 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 // cleanup
array_map('unlink', glob($ssoDir . '/tickets/*') ?: []); array_map('unlink', glob($ssoDir . '/tickets/*') ?: []);
@rmdir($ssoDir . '/tickets'); @rmdir($ssoDir . '/tickets');