Skip to main content

SEC.PRIV.GRANT_EXCESSIVE_IN_MIGRATION — A migration grants everything, or the right to grant it on

  • Category: security
  • Severity: medium
  • Level: 0 — reachable at any strictness; the security suite is gated by security.min_severity
  • Confidence: deterministic
  • Stability: stable
  • Suites: lint, security
  • Applies to: PostgreSQL, MySQL

What it reports

A GRANT in a migration that takes one of two shapes:

  • GRANT ALL [PRIVILEGES] ON … TO <role> — every privilege the object type has, including privileges a later server version adds.
  • GRANT … TO <role> WITH GRANT OPTION — the right to grant the privilege on to somebody else.

ALTER DEFAULT PRIVILEGES … GRANT ALL … is the same statement pointed at objects that do not exist yet, and is reported too.

Why

The two shapes fail differently, and the second one is the one people underestimate.

ALL PRIVILEGES is too much right, held forever. It is what somebody reaches for while getting a migration to run — it makes the error go away, and there is rarely a moment afterwards where anyone comes back to narrow it. Months later, read out of the catalog, it is indistinguishable from a privilege that was arranged deliberately. Nobody removes it, because nobody can tell it apart from a decision.

WITH GRANT OPTION is smaller on the page and larger in effect. It does not widen what this role may do. It makes the role able to widen what others may do — so from that point on, who holds the privilege is decided at runtime, by whoever holds the option, and a later audit can no longer answer "who granted this, and when?" from the repository. Revoking it cascades, which is the other half of the surprise.

What to do

Grant the privileges the role actually uses, by name:

// Instead of:
DB::statement('GRANT ALL PRIVILEGES ON orders TO app_runtime');

// Write:
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_runtime');

The enumerated form is not merely narrower — it is readable. It states what the application does with the table, so the next person can tell whether a new privilege is a requirement or a mistake.

Keep the account that runs migrations separate from the one the application connects with. The migration account legitimately needs DDL rights; the runtime account does not, and a single shared account is what makes an over-grant feel necessary in the first place.

What it does not see

  • It cannot tell an over-grant from a role that is supposed to hold everything. A migration's owner role and an extension's install script both grant broadly by design, and no reading of the statement separates them from a mistake — the difference is in what the role is for. If that decision has been made in your project, silence the id rather than narrowing the rule.
  • It reads the statement, not the server. A privilege this migration revokes again later is still reported, and a privilege the role already held is not.
  • It sees the grantee as a name, not as a role. It cannot know the named role is itself a member of something broader.
  • SEC.PRIV.GRANT_PUBLIC_IN_MIGRATION — the sibling that owns the grantee question. A grant to PUBLIC is reported by that rule and not by this one, even when it is a GRANT ALL … TO PUBLIC: exactly one of the two speaks for any statement, so a single line never produces two findings recommending the same fix.

Suppressing it

// config/sqlens.php
'ignore' => [
['rule' => 'SEC.PRIV.GRANT_EXCESSIVE_IN_MIGRATION', 'reason' => 'the owner role for this schema is meant to hold everything'],
],

A suppression needs a reason, and the reason is read by whoever inherits the project.