Skip to main content

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 arrivesWhere it lives
granted to the runtime rolea grant row naming that role
granted to a role the runtime role is a member ofa grant row naming the other role
the runtime role owns the tablespg_class.relownerno 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.