SEC.PRIV.RUNTIME_DDL — The runtime role may change the schema
- 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
The single most effective thing you can do, and it is not a code change
Run the application on a role that cannot perform DDL, and deploy migrations on a different one.
An SQL injection that reaches a runtime role holding CREATE can add a table, a function or a
trigger — a foothold that outlives the request and the deploy. The same injection against a role
holding only DML reads and writes rows. Both are bad; they are not the same incident, and only one of
them leaves something behind.
What SQLens checks here
It reads privileges, not behavior. A clean result does not mean the application cannot be injected — it means one specific consequence of an injection is off the table. The finding says so, because a security rule that let you believe otherwise would be worse than none.
Three roads to the same privilege
A grant to the runtime role is the obvious one. The other two are the reason a check that reads only grant rows reports silence about a role that can rebuild your schema.
| How the right arrives | Where it lives |
|---|---|
| granted to the runtime role | a grant row naming that role |
| granted to a role the runtime role is a member of | a grant row naming the other role |
| the runtime role owns the tables | pg_class.relowner — no grant row at all |
Membership counts, and it is not a refinement. GRANT app_writer TO app plus
GRANT CREATE ON SCHEMA public TO app_writer gives app the right to create; the grant row names
app_writer. Comparing the grantee's name alone answers no for the arrangement most deploys
actually have.
Ownership is the road with no grant at all. An owner may ALTER and DROP its tables outright,
and ownership follows whoever ran the migration that created them — so every project deploying on its
request-serving connection is in exactly this state. There is nothing in a privilege listing to find.
The ownership half speaks only when the run has established that it is looking at the runtime
connection — --connection naming the connection your security.runtime_connection names. Audit over
the migration connection instead and it stays quiet rather than reporting the migration role as the
runtime one, which would send you to revoke the privilege your deploy needs.
Configure the two connections
// config/sqlens.php
'security' => [
'runtime_connection' => 'pgsql',
'migration_connection' => 'pgsql_migrations',
],
Without them SQLens reports SEC.PRIV.CONNECTIONS_UNSEPARATED as undetermined: it cannot tell which
of your connections is meant to be the safe one, and naming the wrong one would send you to revoke a
privilege your deploy depends on.
Bad
-- One grant, and every request the application serves can add an object to the schema.
GRANT CREATE ON SCHEMA app TO app_runtime;
Good
// config/database.php
'pgsql' => [/* … */ 'username' => env('DB_USERNAME')], // DML only
'pgsql_migrations' => [/* … */ 'username' => env('DB_MIGRATION_USERNAME')],
-- The runtime role, and nothing more than it needs.
GRANT USAGE ON SCHEMA app TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_runtime;
-- The DDL stays with the role that deploys.
REVOKE CREATE ON SCHEMA app FROM app_runtime;
Point Laravel's migrator at the second one:
// In a migration, or via the `--database` option.
protected $connection = 'pgsql_migrations';
The full setup
The snippet above is the shape of the fix, not the whole of it — a runtime role that can read the
tables a future migration creates needs ALTER DEFAULT PRIVILEGES as well, and that is the step
that gets skipped because everything works without it until the next deploy.
Two connections, two roles carries the complete script for both engines, each line verified against a real server, plus the order in which to introduce it into a project that is already running.