Files
alt-mysql/docs/superpowers/plans/2026-07-04-phpmyadmin-all-databases-plan.md
Marek Miklewicz 95b6d774fc Add implementation plan for phpMyAdmin all-databases top-nav access
Bite-sized TDD plan implementing the approved design in
docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md.
2026-07-04 21:22:05 +02:00

17 KiB

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:

// --- 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:

$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 <sso_dir>/tickets/<token>.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:

        $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):

        $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):

        $ticket = $this->writeLoginTicket($auth, $targetDatabase, $restrictToDatabase);

Replace determineGrantTargets (current lines 89-96):

    /**
     * @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):

    /**
     * @param array<string,string|int> $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:

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
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:

# 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:

  $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = ["user" => "x", "password" => "y", "db" => "demo_target_db"];
  session_write_close();

  $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "onlydbtest";

Replace with:

  $_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):

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):

$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth'];
session_write_close();

Replace with:

$_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:

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
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/<version>/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.141.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

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
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 <new-version>"
  • Step 4: Push to Gitea
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql/src && git push origin main
  • Step 5: Build the release package
cd /Users/marek/GPT/ChatGPT/da_plugins/alt-mysql
mkdir -p archives/<new-version>
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/<new-version>/alt-mysql.tar.gz .
rm -rf "$STAGE"
  • Step 6: Verify the package
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.