Skip to content

[FIX] Match synced MySQL users by (username, host) to avoid collapsing multi-host accounts#1075

Open
erhanurgun wants to merge 1 commit intovitodeploy:3.xfrom
erhanurgun:fix/sync-database-users-composite-match
Open

[FIX] Match synced MySQL users by (username, host) to avoid collapsing multi-host accounts#1075
erhanurgun wants to merge 1 commit intovitodeploy:3.xfrom
erhanurgun:fix/sync-database-users-composite-match

Conversation

@erhanurgun
Copy link
Copy Markdown

Summary

  • Fixes SyncDatabaseUsers so it scopes the existing-row lookup by (username, host) whenever the database handler returns a non-empty host, matching how MySQL/MariaDB identify accounts.
  • Prevents a silent data-corruption path where Vito collapses 'user'@'localhost' and 'user'@'127.0.0.1' into a single record, dropping grants and misreporting hosts in the UI.
  • Keeps the username-only match when host is empty, so the PostgreSQL code path (where get-users-list returns '' for host because roles are host-agnostic) is unaffected and does not start generating duplicate rows.
  • Change is confined to app/Actions/Database/SyncDatabaseUsers.php; no schema or migration changes.

Closes #1074

Why

MySQL treats 'user'@'localhost' and 'user'@'127.0.0.1' as two independent accounts with potentially different passwords and grants, a very common setup once an app connects over TCP instead of the unix socket. The previous where('username', ...)->first() collapsed both onto a single database_users row, so:

  • repeated sync iterations overwrote the same row's databases field in an order-dependent way,
  • additional host variants were never inserted (the null branch was unreachable after the first match),
  • the stored host no longer corresponded to the grants shown under it in the UI.

Adding ->where('host', $user[1]) is the natural fix for MySQL/MariaDB, but PostgreSQL's get-users-list view returns an empty string for host (roles are host-agnostic), while CreateDatabaseUser stores PG users with host = 'localhost' by default. An unconditional composite match would therefore miss the existing UI-created row on every sync and append a duplicate (rolname, '') row each time. Gating the host filter on $user[1] !== '' avoids that regression and keeps the existing behaviour for PostgreSQL exactly as before.

The fix brings SyncDatabaseUsers in line with the rest of the codebase (CreateDatabaseUser, UpdateDatabaseUser, DeleteDatabaseUser) which already scope by (username, host) for MySQL/MariaDB.

Change

  /** @var ?DatabaseUser $databaseUser */
- $databaseUser = $server->databaseUsers()
-     ->where('username', $user[0])
-     ->first();
+ $query = $server->databaseUsers()->where('username', $user[0]);
+ // MySQL/MariaDB distinguish users by (username, host); match both to avoid
+ // collapsing distinct accounts onto a single row. PostgreSQL's get-users-list
+ // returns an empty host (roles are host-agnostic), so only filter when present.
+ if ($user[1] !== '') {
+     $query->where('host', $user[1]);
+ }
+ $databaseUser = $query->first();

$user[1] is the Host column already returned by Database::getUsers(); see resources/views/ssh/services/database/{mysql,mariadb}/get-users-list.blade.php (returns actual host strings like localhost / 127.0.0.1) and resources/views/ssh/services/database/postgresql/get-users-list.blade.php (selects '' AS host, triggering the new early-return branch).

Test plan

  • vendor/bin/phpunit tests/Feature/DatabaseUserTest.php (adds three new regression tests covering MySQL multi-host sync, MySQL sync idempotency and the PostgreSQL no-duplicate-row invariant)
  • Manual reproduction (MySQL/MariaDB): on a Vito-managed server, create 'u'@'localhost' and 'u'@'127.0.0.1' with differing grants, then hit PATCH /servers/{server}/database-users/sync.
    • Before this patch: a single database_users row whose databases column flips between the two grant sets depending on MySQL row order; host stays stuck on the first one.
    • After this patch: two distinct rows, each with the grants that apply to that specific (username, host) pair.
  • Regression check (MySQL single-host): a server that only has 'u'@'localhost' (the common case) continues to sync with no duplicate rows created.
  • Regression check (PostgreSQL): on a Vito-managed Postgres server, create a user through the UI (stored host localhost), then hit the sync endpoint repeatedly. The existing row must keep being matched and updated; no (rolname, '') duplicates should be inserted.

Notes for reviewers

  • Intentionally minimal: no refactor, no change to insertion semantics, no removal of stale users.
  • The host filter is only applied when the handler returns a non-empty host so the PostgreSQL path (host-agnostic roles) is not disturbed. An alternative would be to push the comparison down into each Database handler implementation; that felt like over-engineering for a single call site.
  • SyncDatabases is not affected by the analogous class of bug because schema names are globally unique in MySQL/PostgreSQL, so its where('name', ...) lookup is already sufficient.
  • A follow-up could also add a composite unique index (server_id, username, host) on database_users to make the invariant enforced at the DB layer, but that's a migration-level change and out of scope for this fix.

…g multi-host accounts

SyncDatabaseUsers previously matched existing database_users rows by username
only. MySQL and MariaDB treat 'user'@'localhost' and 'user'@'127.0.0.1' as
independent accounts with potentially different passwords and grants, so
collapsing them onto a single row caused silent data corruption: the databases
column was overwritten in an order-dependent way, additional host variants
were never inserted, and the stored host no longer matched the grants shown
under it.

The lookup now scopes by (username, host) whenever the handler returns a
non-empty host, aligning sync with CreateDatabaseUser, UpdateDatabaseUser and
DeleteDatabaseUser. PostgreSQL keeps the original username-only match because
its get-users-list view returns an empty host (roles are host-agnostic); an
unconditional composite filter would miss the existing UI-created row and
insert a duplicate (rolname, '') record on every sync.

Adds regression tests covering MySQL multi-host sync, MySQL sync idempotency
and the PostgreSQL no-duplicate-row invariant.
@erhanurgun erhanurgun marked this pull request as ready for review April 22, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: SyncDatabaseUsers corrupts databases column when multiple MySQL users share a username across different hosts

1 participant