diff --git a/docs/superpowers/plans/2026-07-04-phpmyadmin-all-databases-plan.md b/docs/superpowers/plans/2026-07-04-phpmyadmin-all-databases-plan.md new file mode 100644 index 0000000..997af90 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-phpmyadmin-all-databases-plan.md @@ -0,0 +1,423 @@ +# phpMyAdmin All-Databases Top-Nav Access Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore "grant access to every owned database" behavior for the top-nav PHPMYADMIN button while keeping the per-row "Zaloguj do bazy" button scoped to a single database. + +**Architecture:** `PhpMyAdminSso::issueLoginPayload()` treats "no database requested" as its own explicit case (grant all, don't restrict) instead of defaulting it into "scope to the first database." A new `restrict_db` field flows from the ticket payload into the phpMyAdmin SSO session, and `config.inc.php`'s `only_db` builder reads that field instead of the landing-page `db` field. + +**Tech Stack:** PHP 8 (plugin classes), bash heredocs generating standalone PHP files (`da_login.php`, `config.inc.php`) consumed by phpMyAdmin. + +## Global Constraints + +- No new "Adminer"-branded identifiers; new names use `phpmyadmin`/`PhpMyAdmin`/`DA_MYSQL_PHPMYADMIN` conventions (per spec `docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md`). +- The per-row "Zaloguj do bazy" button's behavior (grant + restrict to one database) must not change. +- Every task must leave `bash tests/*.sh` and `php tests/*.php` green. + +--- + +### Task 1: Scope-aware grant targets and ticket payload in PhpMyAdminSso + +**Files:** +- Modify: `exec/lib/PhpMyAdminSso.php:20-96` (`issueLoginPayload`, `determineGrantTargets`, `writeLoginTicket`) +- Test: `tests/phpmyadmin_sso_test.php` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `PhpMyAdminSso::determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array` (signature change — was `string $targetDatabase`), and a new `restrict_db` string key in the ticket JSON payload written by `writeLoginTicket()`. Task 2 reads this exact key name from the ticket. + +- [ ] **Step 1: Write the failing test** + +Open `tests/phpmyadmin_sso_test.php`. Replace the existing `determineGrantTargets` block (currently lines 64-80) with: + +```php +// --- 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'); +$determineGrantTargets = $ssoClassForGrants->getMethod('determineGrantTargets'); +$determineGrantTargets->setAccessible(true); + +assert_same( + ['demo_target'], + $determineGrantTargets->invoke(null, 'demo_target', ['demo_target', 'demo_other']), + '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 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' +); +``` + +Then replace the `writeLoginTicket` invocation block (currently lines 96-118) with: + +```php +$writeTicket = $ssoClass->getMethod('writeLoginTicket'); +$writeTicket->setAccessible(true); +$auth = [ + 'user' => 'da_tmp_phpmyadmin_demo_test', + 'password' => 'secret-password', + 'host' => '127.0.0.1', + 'port' => 33033, + 'db' => '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'); + +$ticketPath = $ssoDir . '/tickets/' . $token . '.json'; +assert_true(is_file($ticketPath), 'ticket file must be written to /tickets/.json'); + +$mode = fileperms($ticketPath) & 0777; +assert_same(0640, $mode, 'ticket files contain a plaintext MySQL password and must not be world-readable (expected 0640)'); + +$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/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)'); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && php tests/phpmyadmin_sso_test.php` +Expected: FAIL — either an arity/type error calling `writeLoginTicket` with 4 arguments (current signature only accepts 3), or the `null`/`''` `determineGrantTargets` assertions failing (current implementation treats a `string $targetDatabase` positionally, `null`/`''` won't behave as "grant all" yet). + +- [ ] **Step 3: Write minimal implementation** + +In `exec/lib/PhpMyAdminSso.php`, replace the block from `$targetDatabase = '';` through `$targetDatabase = $databaseNames[0]; }` (current lines 42-54) with: + +```php + $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.'); + } + $normalizedRequestedDatabase = $requestedDatabase; + } + } + + $targetDatabase = $normalizedRequestedDatabase ?? $databaseNames[0]; + $restrictToDatabase = $normalizedRequestedDatabase ?? ''; +``` + +Replace the grant loop (current lines 61-72): + +```php + $this->db->createTemporaryRole($role, $password, $expiresAt); + try { + foreach (self::determineGrantTargets($normalizedRequestedDatabase, $databaseNames) as $dbName) { + $this->db->grantTemporaryAdminerAccess($dbName, $role); + } + } catch (Throwable $e) { + try { + $this->db->dropTemporaryRole($role); + } catch (Throwable $ignored) { + } + throw new RuntimeException('Nie udało się przygotować sesji phpMyAdmin: ' . $e->getMessage(), 0, $e); + } +``` + +Replace the `writeLoginTicket` call (current line 81): + +```php + $ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase); +``` + +Replace `determineGrantTargets` (current lines 89-96): + +```php + /** + * @param string[] $ownedDatabases + * @return string[] + */ + private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array + { + if ($requestedDatabase === null || $requestedDatabase === '') { + return $ownedDatabases; + } + return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : []; + } +``` + +Replace `writeLoginTicket`'s signature and payload (current lines 139-156): + +```php + /** + * @param array $auth + */ + 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)) { + throw new RuntimeException('Nie można utworzyć katalogu ticketów phpMyAdmin: ' . $ticketDir); + } + + $this->cleanupExpiredTickets($ticketDir); + + $token = bin2hex(random_bytes(32)); + $payload = [ + 'version' => 1, + 'created_at' => time(), + 'expires_at' => time() + $this->settings->adminerTicketTtl(), + 'auth' => $auth, + 'route' => '/database/structure', + 'db' => $database, + 'restrict_db' => $restrictToDatabase, + ]; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && php tests/phpmyadmin_sso_test.php` +Expected: `phpmyadmin_sso_test: OK` + +Also run the full local suite to confirm no regressions: + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +find . -name "*.php" | xargs -I{} php -l {} 2>&1 | grep -v "No syntax errors" +cd tests +for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done +for f in *_test.php; do php "$f" || echo "FAIL: $f"; done +``` +Expected: every line ends `OK`, no `FAIL:` lines, no `php -l` output. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +git add exec/lib/PhpMyAdminSso.php tests/phpmyadmin_sso_test.php +git commit -m "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." +``` + +--- + +### Task 2: Thread restrict_db through da_login.php session and config.inc.php only_db + +**Files:** +- Modify: `scripts/setup/phpmyadmin_install.sh` (the `da_login.php` heredoc around line 517, and the `config.inc.php` heredoc's `da_mysql_phpmyadmin_only_db()` function around lines 340-361) +- Test: `tests/phpmyadmin_install_test.sh` + +**Interfaces:** +- Consumes: ticket JSON's `restrict_db` key produced by Task 1's `writeLoginTicket()`. +- Produces: PHP session key `$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB']`, read by `da_mysql_phpmyadmin_only_db()`. + +- [ ] **Step 1: Write the failing test** + +Open `tests/phpmyadmin_install_test.sh`. Find the existing `only_db` scenario (the block starting with the comment `# config.inc.php must restrict phpMyAdmin's visible database...` and the `ONLY_DB=` assignment). Immediately after that block's `fail` line, add a second scenario: + +```bash +# When no specific database was requested (the top-nav PHPMYADMIN button), +# only_db must be empty so phpMyAdmin shows every granted database. +ALL_DB_ONLY_DB="$(php -d session.save_path="$SESSION_DIR" -r ' + session_name("DA_MYSQL_PHPMYADMIN"); + session_id("alldbtest"); + session_start(); + $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_landing_db"]; + $_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = ""; + session_write_close(); + + $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "alldbtest"; + $cfg = []; + include $argv[1]; + echo $cfg["Servers"][1]["only_db"] ?? "(unset)"; +' "$CONFIG_INC")" +[ "$ALL_DB_ONLY_DB" = "" ] \ + || fail "config.inc.php only_db must be empty for an all-databases login, got: '$ALL_DB_ONLY_DB'" +``` + +Also update the *existing* `only_db` scenario's session payload to set `DA_MYSQL_PHPMYADMIN_RESTRICT_DB` explicitly (matching what Task 1's ticket now produces) instead of relying on the auth `db` field: + +Find: +```bash + $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"]; + session_write_close(); + + $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest"; +``` +Replace with: +```bash + $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"]; + $_SESSION["DA_MYSQL_PHPMYADMIN_RESTRICT_DB"] = "demo_target_db"; + session_write_close(); + + $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest"; +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/phpmyadmin_install_test.sh` +Expected: FAIL on `config.inc.php only_db should be scoped to the SSO ticket's database` (since `only_db` still reads the auth `db` field, which is `demo_target_db` regardless of the new `RESTRICT_DB` session key — this proves the test exercises the not-yet-updated code). + +- [ ] **Step 3: Write minimal implementation** + +In `scripts/setup/phpmyadmin_install.sh`, replace `da_mysql_phpmyadmin_only_db()`'s body (current lines 340-361): + +```php +function da_mysql_phpmyadmin_only_db(): string +{ + $sessionName = 'DA_MYSQL_PHPMYADMIN'; + + if (session_status() === PHP_SESSION_ACTIVE) { + return ''; + } + + $sessionId = (string)($_COOKIE[$sessionName] ?? ''); + if ($sessionId === '' || preg_match('/\A[A-Za-z0-9,-]+\z/', $sessionId) !== 1) { + return ''; + } + + session_name($sessionName); + session_id($sessionId); + session_start(); + $db = (string)($_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] ?? ''); + session_write_close(); + + return $db; +} +``` + +Then find the `da_login.php` heredoc's session-writing line (current line 517): + +```php +$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth']; +session_write_close(); +``` + +Replace with: + +```php +$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth']; +$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] = (string)($ticket['restrict_db'] ?? ''); +session_write_close(); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/phpmyadmin_install_test.sh` +Expected: `phpmyadmin_install_test: OK` + +Run the full local suite again: + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +bash -n scripts/setup/phpmyadmin_install.sh +cd tests +for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done +for f in *_test.php; do php "$f" || echo "FAIL: $f"; done +``` +Expected: every line ends `OK`, no `FAIL:` lines. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +git add scripts/setup/phpmyadmin_install.sh tests/phpmyadmin_install_test.sh +git commit -m "Read only_db restriction from a dedicated session key + +da_login.php now stores the ticket's restrict_db into +DA_MYSQL_PHPMYADMIN_RESTRICT_DB, and config.inc.php's only_db builder +reads that key instead of the landing-page db field, so an +all-databases login (empty restrict_db) leaves phpMyAdmin unrestricted." +``` + +--- + +### Task 3: Version bump, full verification, commit, push, package + +**Files:** +- Modify: `plugin.conf` +- Modify: `tests/plugin_identity_test.sh` +- Modify: `tests/package_archive_test.sh` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `archives//alt-mysql.tar.gz`. + +- [ ] **Step 1: Bump the version** + +Read `plugin.conf`, note the current `version=X.Y.Z` line, and bump the patch number by 1 (e.g. `1.2.14` → `1.2.15`). Apply the same new version string to: +- `plugin.conf`'s `version=` line +- `tests/plugin_identity_test.sh`'s `grep -Fxq "version=X.Y.Z"` line +- `tests/package_archive_test.sh`'s `archives/X.Y.Z/alt-mysql.tar.gz` default path and its `version=X.Y.Z` grep line + +- [ ] **Step 2: Run the full test suite** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +find . -name "*.php" | xargs -I{} php -l {} 2>&1 | grep -v "No syntax errors" +find . -name "*.sh" | xargs -I{} bash -n {} 2>&1 +cd tests +for f in *_test.sh; do bash "$f" || echo "FAIL: $f"; done +for f in *_test.php; do php "$f" || echo "FAIL: $f"; done +``` +Expected: every test prints `OK`; `package_archive_test.sh` is expected to fail here (archive not built yet) — that single failure is acceptable at this step. + +- [ ] **Step 3: Commit the version bump** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src +git add plugin.conf tests/plugin_identity_test.sh tests/package_archive_test.sh +git commit -m "Bump version to " +``` + +- [ ] **Step 4: Push to Gitea** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && git push origin main +``` + +- [ ] **Step 5: Build the release package** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql +mkdir -p archives/ +STAGE="$(mktemp -d)" +cp -a src/. "$STAGE"/ +rm -rf "$STAGE/tests" "$STAGE/.git" +rm -f "$STAGE/.gitignore" "$STAGE/error.log" +find "$STAGE" -name ".DS_Store" -delete +find "$STAGE" -name "._*" -delete +tar -C "$STAGE" -czf archives//alt-mysql.tar.gz . +rm -rf "$STAGE" +``` + +- [ ] **Step 6: Verify the package** + +```bash +cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && bash tests/package_archive_test.sh +``` +Expected: `package_archive_test: OK` + +--- + +## Self-Review Notes + +- **Spec coverage:** Task 1 covers the spec's `determineGrantTargets`/ticket-payload section. Task 2 covers the `da_login.php` session and `config.inc.php` `only_db` sections. Task 3 covers packaging (spec's "out of scope" items — Adminer renaming, UI changes — are correctly not tasked). +- **Placeholder scan:** no TBD/TODO; all code blocks are complete and copy-pasteable. +- **Type consistency:** `determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array` and `writeLoginTicket(array $auth, string $database, string $restrictToDatabase): string` signatures match between Task 1's implementation step and its test step. The `restrict_db` key name matches between Task 1's ticket payload and Task 2's session-read code.