diff --git a/docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md b/docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md new file mode 100644 index 0000000..7538dc0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-phpmyadmin-all-databases-design.md @@ -0,0 +1,144 @@ +# phpMyAdmin: all-databases access from the top-nav button + +## Context + +The plugin's page header (`AppContext::renderTopActions()`) renders a +top-level "PHPMYADMIN" button, separate from the per-row "Zaloguj do +bazy" button in the database table. Both POST to the same +`open_phpmyadmin.raw` action and go through `PhpMyAdminSso`, but they +differ in one respect: the row button always sends `adminer_db=`; +the top-nav button never sends `adminer_db` at all. + +A recent security fix (scoping the temporary SSO MySQL role to only the +requested database instead of every database the account owns) treated +both cases identically: when no database was requested, it defaulted to +the first owned database and granted only that one. This is correct for +the row button, but broke the top-nav button's intent, which is to open +phpMyAdmin with access to every database the DirectAdmin user owns. + +## Goal + +Restore "access to all owned databases" for the top-nav PHPMYADMIN +button, without reintroducing over-broad access for the per-row button. + +## Design + +### `PhpMyAdminSso::issueLoginPayload()` + +Treat "no database requested" (`$requestedDatabase === null` or empty +after trim) as its own explicit case, not as "default to the first +database and scope to it." + +Extract a pure, testable decision function: + +```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] : []; +} +``` + +This replaces the current `determineGrantTargets(string $targetDatabase, array $ownedDatabases)`, +which took the already-resolved target database and could no longer +distinguish "explicitly requested this one" from "nothing was requested." + +`issueLoginPayload()` keeps resolving a concrete `$targetDatabase` for +the phpMyAdmin *landing page* route (`route=/database/structure&db=...`) +exactly as it does today — defaulting to the first owned database when +none was requested. That landing behavior is unaffected; only the grant +scope and the `only_db` restriction change. + +A new local value, `$restrictToDatabase`, is derived alongside the +grant targets: empty string when granting all databases, or the single +target database name when scoped to one. This value is written into the +ticket payload as a new field, `restrict_db`, separate from the +existing `db` field (which continues to mean "which database to land +on," not "which database(s) to allow"). + +### Ticket payload + +```json +{ + "version": 1, + "created_at": 0, + "expires_at": 0, + "auth": { "user": "...", "password": "...", "host": "...", "port": 0, "db": "..." }, + "route": "/database/structure", + "db": "landing_database", + "restrict_db": "" +} +``` + +`restrict_db` is `""` for the top-nav (all-databases) case, or the +specific database name for the per-row case. + +### `da_login.php` (generated by `phpmyadmin_install.sh`) + +When consuming a ticket, store `restrict_db` into the SSO session under +a new key, alongside the existing auth payload: + +```php +$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth']; +$_SESSION['DA_MYSQL_PHPMYADMIN_RESTRICT_DB'] = (string)($ticket['restrict_db'] ?? ''); +``` + +### `config.inc.php`'s `only_db` builder + +The existing `da_mysql_phpmyadmin_only_db()` function currently reads +`$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH']['db']`. It will instead read the +new `DA_MYSQL_PHPMYADMIN_RESTRICT_DB` session key, defaulting to `''` +(no restriction) when absent. An empty value means phpMyAdmin's +`only_db` stays unset, showing every database the temporary role has +been granted. + +## Data flow + +- Row button: POST includes `adminer_db=admin_wiesio` → handler passes + `'admin_wiesio'` to `issueLoginPayload()` → grants only `admin_wiesio`, + `restrict_db=admin_wiesio` → phpMyAdmin shows only that database. +- Top-nav button: POST has no `adminer_db` field → handler's existing + `if ($requestedDb === '') { $requestedDb = null; }` passes `null` → + `issueLoginPayload(null)` → grants every owned database, + `restrict_db=''` → phpMyAdmin shows all databases, landing on the + first one's structure page (unchanged from today). + +## Error handling + +Unchanged. If the DirectAdmin user owns zero databases, both entry +points still throw "Użytkownik nie ma żadnej bazy MySQL do otwarcia w +phpMyAdmin." before reaching the grant-scope logic. + +## Naming + +No new "Adminer"-branded identifiers. New field/session key names +(`restrict_db`, `DA_MYSQL_PHPMYADMIN_RESTRICT_DB`) follow the existing +`da_mysql_*` / `DA_MYSQL_*` convention already used throughout the +generated phpMyAdmin integration files. Pre-existing legacy names like +`MySQLService::grantTemporaryAdminerAccess()` are out of scope for this +change. + +## Testing + +- `tests/phpmyadmin_sso_test.php`: update the existing + `determineGrantTargets` assertions for the new `?string` signature, + and add a case asserting that `null`/`''` returns every owned + database. +- `tests/phpmyadmin_install_test.sh`: extend the existing `only_db` + PHP-fixture check with a second scenario — a session with + `DA_MYSQL_PHPMYADMIN_RESTRICT_DB` unset/empty — asserting + `$cfg['Servers'][1]['only_db']` is `''` (not the landing `db` value). + +## Out of scope + +- Renaming legacy "Adminer"-named methods/settings elsewhere in the + codebase. +- Any change to the row button's behavior (already correct). +- Any new UI element — the top-nav button already exists and already + omits `adminer_db`; no markup changes are needed.