Skip to main content

SEC.PRIV.CONNECTIONS_UNSEPARATED — One connection serves requests and deploys migrations

  • Category: security
  • Severity: medium
  • Level: 0
  • Confidence: deterministic
  • Downtime class: none — the finding is about a privilege, not about a statement
  • Stability: stable
  • Suites: audit
  • Applies to: PostgreSQL 18 · MySQL 8.4

Why this is its own finding

If the application serves requests and deploys schema changes over the same connection, its role holds whatever the migrations need — CREATE, usually DROP. An injection that reaches a request inherits all of it.

This is reported separately from SEC.PRIV.RUNTIME_DDL because the two answer different questions and one of them has to speak when the other has nothing to look at: the DDL rule judges a grant, and this one judges the configuration — including the case where nothing is configured at all, which is exactly the state a silent suite would leave you in.

The states, and the answer to each

security.runtime_connectionsecurity.migration_connectionSQLens says
unsetunsetundetermined — it cannot guess which connection is meant to be safe
pgsqlunset, or the same namefinding — one connection does both jobs
pgsqlpgsql_migrations, same user/host/databasefinding — two names, one identity
pgsqlpgsql_migrations, credentials not resolvableundetermined — it cannot tell whether they are the same
pgsqlpgsql_migrations, different usernothing

The two undetermined rows are deliberate, and they are not the same statement. The first says nobody has answered the question; the second says the answer exists but this run cannot read it.

Guessing on the first would be right by accident on a project with one connection and wrong on a project with five, where it would name a role somebody then revokes a privilege from — breaking the next deploy.

Two names are not two identities

The third row is the one worth reading twice, because it is the state that looks solved.

A second entry in config/database.php is a name. If it authenticates as the same user against the same host and the same database, the separation exists in your configuration file and nowhere else: requests are still served by the account your migrations run as, holding whatever DDL they need. Nothing about the setup looks wrong, and somebody deliberately created that second entry.

So the check compares username, host and database — not the names you filed them under.

// config/database.php — two names, one identity. Still a finding.
'mysql' => ['username' => 'app', 'host' => 'db.internal', 'database' => 'shop', /* … */],
'mysql_migrations' => ['username' => 'app', 'host' => 'db.internal', 'database' => 'shop', /* … */],
// Two names, two identities. This is the state the check is looking for.
'mysql' => ['username' => 'app', 'host' => 'db.internal', 'database' => 'shop', /* … */],
'mysql_migrations' => ['username' => 'deployer', 'host' => 'db.internal', 'database' => 'shop', /* … */],

When credentials only arrive at runtime — resolved from the environment rather than written in the file — the comparison has nothing to work with, and the answer is the fourth row rather than the fifth. Reporting "these are separate" there would certify a separation nobody could see.

Bad

// config/database.php — one connection serves requests AND deploys migrations
return [
'connections' => [
'pgsql' => ['driver' => 'pgsql', 'username' => env('DB_USERNAME')],
],
];

Good

// config/database.php — the runtime role holds DML, the migration role holds the DDL
return [
'connections' => [
'pgsql' => ['driver' => 'pgsql', 'username' => env('DB_USERNAME')],
'pgsql_migrations' => ['driver' => 'pgsql', 'username' => env('DB_MIGRATION_USERNAME')],
],
'migrations' => ['connection' => 'pgsql_migrations', 'table' => 'migrations'],
];

The fix

Add a second connection whose role holds only DML, point the application at it, and keep the DDL rights on the migration one. SEC.PRIV.RUNTIME_DDL has the SQL.

If the project genuinely uses one connection and that is a decision rather than an oversight, record it where the next reader will find it:

// config/sqlens.php
'ignore' => [
['rule' => 'SEC.PRIV.CONNECTIONS_UNSEPARATED', 'reason' => 'single-connection deployment, accepted 2026-07'],
],

Setting up the second connection

This rule reads configuration; it says nothing about what the two identities may actually do on the server. Creating them, and giving the runtime one exactly the privileges it needs, is Two connections, two roles — including the introduction order for an existing project, where revoking before granting is what takes production down.