Skip to main content

SEC.PRIV.GRANT_PUBLIC_IN_MIGRATION — A migration grants a privilege to PUBLIC

  • Category: security
  • Severity: medium
  • Level: 0
  • Confidence: deterministic
  • Stability: stable
  • Suites: lint, security
  • Applies to: PostgreSQL 18

The same fact, one deploy earlier

SEC.PRIV.GRANT_PUBLIC reads your live catalog and reports a privilege PUBLIC already holds. By then the grant has been applied, and undoing it is a change to a running database that somebody has to schedule and justify.

This rule reads the migration. It is the same fact at the only moment it is still a line in a file somebody can delete.

What it flags

Any grant whose target is PUBLIC:

DB::statement('GRANT SELECT ON orders TO PUBLIC');
DB::statement('ALTER DEFAULT PRIVILEGES IN SCHEMA reporting GRANT SELECT ON TABLES TO PUBLIC');

The second one is worth its own sentence: a default privilege reaches objects that do not exist yet. Every table created in that schema afterwards arrives already readable by every role, and nothing at the moment of creation mentions it.

What it deliberately does not flag

A grant to a named role — however broad:

DB::statement('GRANT SELECT ON orders TO app_reader'); // the shape we recommend
DB::statement('GRANT ALL PRIVILEGES ON orders TO app_writer'); // broad, but a different rule
DB::statement('GRANT SELECT ON orders TO app_reader WITH GRANT OPTION');

GRANT ALL PRIVILEGES and WITH GRANT OPTION are findings too, and they carry their own rule id. The split is not tidiness: under one shared id you could not accept GRANT USAGE ON SCHEMA … TO PUBLIC — which is common and much less sharp — without blinding yourself to GRANT ALL ON TABLE … TO PUBLIC at the same time, and the two would not be separable in a baseline either.

A role that happens to be named public_reader is an ordinary role and is not reported.

Why the severity does not vary

GRANT USAGE ON SCHEMA and GRANT ALL ON TABLE both report as medium. A rule that graded itself would take away the only dial you have over this suite, which is security.min_severity. What you need in order to judge a particular one is the privilege and the object — so the finding names both, and leaves the judgment where it belongs.

Fixing it

Grant to a named role, and keep the account your application connects with separate from the account that runs migrations:

DB::statement('GRANT SELECT ON orders TO app_reader');

If a privilege genuinely belongs to everyone — USAGE on a schema that holds nothing sensitive is the usual case — record that decision in your baseline rather than in silence. The finding is then accepted on purpose, and the next reader can see that somebody decided it.

What this rule does not see

It reads a statement, not a server. It cannot know whether a later statement in the same migration takes the grant away again, and it says nothing about privileges your database already holds — a live catalog is the audit suite's subject.

It also does not bind itself to database/migrations. A GRANT … TO PUBLIC in a seeder opens the table exactly as wide, so it is reported wherever it is written. The secrets rules do bind to the migration directories, because a password literal in a seeder is test data rather than a leak; that asymmetry is deliberate.