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_connection | security.migration_connection | SQLens says |
|---|---|---|
| unset | unset | undetermined — it cannot guess which connection is meant to be safe |
pgsql | unset, or the same name | finding — one connection does both jobs |
pgsql | pgsql_migrations, same user/host/database | finding — two names, one identity |
pgsql | pgsql_migrations, credentials not resolvable | undetermined — it cannot tell whether they are the same |
pgsql | pgsql_migrations, different user | nothing |
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.