Least privilege means every database account holds exactly the permissions its job requires and nothing more. This page documents the 6 checks in the CtrlOps Least-Privilege Permissions audit for MySQL and PostgreSQL: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.
It is the layer that decides how much damage a leaked credential does. Authentication controls whether an attacker gets in; least privilege controls what they find when they do. Every threshold below is transcribed from the audit script itself, not from general advice.
Key takeaways
Six checks, all MEDIUM, all on MySQL and PostgreSQL, all measuring the same thing from different angles: how much more access exists than anyone needs.
- Every check is MEDIUM severity, and that is deliberate. None of these is an open door on its own. Each one is an amplifier: it decides whether a compromised application account means one database or the whole server.
- All six need root, because each opens an administrative connection to read a privilege table or the live host rules file. Without sudo the audit reports SKIP and names the reason.
- The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 to 20 minutes by hand.
- MongoDB and Redis are out of scope here. The audit covers MySQL and PostgreSQL because those two have the granular privilege models these checks interrogate.
| # | Check | Engine | Severity | What it catches |
|---|---|---|---|---|
| 1 | Wildcard hosts | MySQL | MEDIUM | Accounts reachable from any address |
| 2 | FILE privilege | MySQL | MEDIUM | Accounts that can read server files through SQL |
| 3 | File import/export | MySQL | MEDIUM | Unrestricted file operations and local_infile |
| 4 | Admin accounts | MySQL | MEDIUM | SUPER held beyond the system set |
| 5 | Superusers | PostgreSQL | MEDIUM | Roles that bypass every access control |
| 6 | Allowed hosts | PostgreSQL | MEDIUM | Host rules open to every address |
root, mysql, mysql.sys, mysql.session, mysql.infoschema, mariadb.sys, debian-sys-maint and percona.telemetry. Those are packaged accounts that legitimately hold elevated privileges, and flagging them would mean every server fails forever. Anything outside that list holding FILE or SUPER is the finding, including an account you created and named something sensible.Check 1: Can any MySQL account connect from any host?
MySQL identifies an account by a user and host pair, so appuser@10.0.1.5 and appuser@% are different accounts with different reach. A host of % means the account accepts connections from any address that can reach the port. The password still applies, but you have removed a whole layer of defence: a leaked credential is now usable from anywhere rather than from one machine.
The audit lists every account whose host is exactly %, skipping the blank username that check 2 of the authentication audit already covers.
How to check manually
sudo mysql -N -e "SELECT CONCAT(user,'@',host) FROM mysql.user WHERE host='%' AND user<>''"| Result | When | What it means |
|---|---|---|
| PASS | No account uses the wildcard host | Every account is pinned to a specific address or to localhost. |
| WARN | One or more accounts use it | The accounts are listed as user@host. Pin each to localhost or to the application server address. |
| SKIP | MySQL is not installed, or is not accessible | The two reasons are reported separately. |
This warns rather than failing because a wildcard host is sometimes the only workable answer: an autoscaling application tier has no fixed address to pin to. In that situation, narrow it to a subnet rather than leaving it at %, and rely on the network isolation audit to make sure the port is not reachable from outside that subnet in the first place.
How to fix it
# Add the scoped account FIRST
sudo mysql -e "
CREATE USER 'appuser'@'10.0.1.5' IDENTIFIED BY 'the-existing-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'10.0.1.5';"
# Verify the application works, then remove the wildcard one
sudo mysql -e "DROP USER 'appuser'@'%'; FLUSH PRIVILEGES;"For a dynamic application tier, scope to the private subnet instead: 'appuser'@'10.0.1.%'.
Check 2: Can a MySQL account read files off the server?
The FILE privilege lets an account read any file the MySQL process can read, and write files anywhere it can write. That includes /etc/passwd, application source, and the .env file the credential storage audit is worried about.
The reason this matters more than it sounds: it converts a SQL injection from a database problem into a filesystem read. An attacker who can inject LOAD_FILE('/var/www/html/.env') does not need to escalate anything.
The audit lists non-system accounts where File_priv is Y.
How to check manually
sudo mysql -N -e "
SELECT CONCAT(user,'@',host) FROM mysql.user WHERE File_priv='Y'
AND user NOT IN ('root','mysql','mysql.sys','mysql.session','mysql.infoschema',
'mariadb.sys','debian-sys-maint','percona.telemetry')"| Result | When | What it means |
|---|---|---|
| PASS | No application account holds FILE | A SQL injection stays inside the database. |
| FAIL | One or more non-system accounts hold it | The accounts are listed. Revoke FILE on each. |
| SKIP | MySQL is not accessible | Not installed, server down, or root was unavailable. |
This is the only check on the page that fails rather than warning, despite carrying the same MEDIUM severity as the rest. The difference is that unlike a wildcard host or an extra superuser, there is no legitimate configuration in which an application account needs FILE. If something is using it, that is worth finding out about rather than accepting.
How to fix it
sudo mysql -e "REVOKE FILE ON *.* FROM 'appuser'@'localhost'; FLUSH PRIVILEGES;"If an application genuinely uses LOAD DATA INFILE, keep FILE revoked and restrict the operation instead, which is check 3.
Check 3: Is MySQL file import and export restricted?
Two settings decide how much filesystem MySQL exposes through SQL:
secure_file_privconfinesLOAD DATA INFILEandSELECT INTO OUTFILEto one directory. Empty means anywhere.NULLmeans the operations are disabled entirely.local_infilelets a client push a local file to the server withLOAD DATA LOCAL INFILE, which bypassessecure_file_privcompletely and can be abused by a malicious or compromised server to read files off the client.
The audit reads both and combines them. local_infile being on is a warning regardless of anything else.
How to check manually
sudo mysql -e "SHOW VARIABLES LIKE 'secure_file_priv'"
sudo mysql -N -e "SELECT @@local_infile"| Result | When | What it means |
|---|---|---|
| PASS | secure_file_priv is NULL or set to a directory, and local_infile is off | The actual value is reported. File operations are confined or disabled outright. |
| WARN | local_infile is on, or secure_file_priv is empty | The reason is named. An empty value means any directory; local_infile on means a SQL injection can read client-side files. |
| SKIP | MySQL is not accessible, or secure_file_priv could not be read at all | The second case is deliberate: a failed query is not the same as an unrestricted setting, so it is not reported as one. |
That last SKIP branch is worth understanding, because it is the difference between a useful check and a false alarm. An empty secure_file_priv value means unrestricted access. A query that returns no row at all means the audit could not read the setting. Those look identical if you only capture the value, so the script keeps the whole row and distinguishes them.
How to fix it
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
secure_file_priv = /var/lib/mysql-files/ # or NULL to disable entirely
local_infile = 0
sudo systemctl restart mysqlNULL is stronger than a directory. Setting secure_file_priv to a path keeps import and export working inside that path. Setting it to NULL disables them completely, which is the right answer on the large majority of servers, because almost nothing in normal application traffic uses LOAD DATA INFILE. Both pass this check; only one of them removes the capability.Check 4: Do any application accounts hold SUPER?
SUPER (and SYSTEM_USER on MySQL 8) allows operations that override normal restrictions: killing other sessions, changing global variables, bypassing read-only mode, skipping replication filters. It belongs to maintenance accounts, not to the account your web application connects with.
The audit lists accounts with Super_priv set to Y, excluding the same packaged system set as check 2.
How to check manually
sudo mysql -N -e "
SELECT CONCAT(user,'@',host) FROM mysql.user WHERE Super_priv='Y'
AND user NOT IN ('root','mysql','mysql.sys','mysql.session','mysql.infoschema',
'mariadb.sys','debian-sys-maint','percona.telemetry')"| Result | When | What it means |
|---|---|---|
| PASS | No unexpected account holds SUPER | Administrative privilege is confined to the system accounts. Keep application accounts on table-level grants. |
| WARN | One or more accounts hold it beyond the system set | The accounts are listed. Application accounts need SELECT, INSERT, UPDATE and DELETE, not admin rights. |
| SKIP | MySQL is not accessible | Not installed, server down, or root was unavailable. |
How to fix it
sudo mysql -e "REVOKE SUPER ON *.* FROM 'appuser'@'localhost'; FLUSH PRIVILEGES;"
# What an application account should actually have
sudo mysql -e "
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'appuser'@'localhost';"
# A migration account, separate from the runtime one
sudo mysql -e "
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, DROP, INDEX
ON appdb.* TO 'migrate'@'localhost';"SUPER is that it also runs migrations, and somebody granted broadly to make a deploy work at 2am. Splitting the runtime account from the migration account fixes the finding permanently: the long-lived credential in your application config gets table-level access only, and the DDL rights live on an account that is used for minutes at a time.Check 5: How many PostgreSQL superusers are there?
A PostgreSQL superuser bypasses everything: row-level security, role permissions, object ownership, all of it. The built-in postgres role is expected. Every additional one is another credential that, if leaked, is equivalent to root for the entire cluster.
The audit lists roles where rolsuper is true, excluding postgres.
How to check manually
sudo -u postgres psql -X -tAc "SELECT rolname FROM pg_roles WHERE rolsuper AND rolname <> 'postgres'"| Result | When | What it means |
|---|---|---|
| PASS | postgres is the only superuser role | Superuser access is as small as it goes. |
| WARN | Any other role has the superuser attribute | The role names are listed. Application roles should own their schema, not the cluster. |
| SKIP | PostgreSQL is not installed, or is not accessible | The two reasons are reported separately. |
How to fix it
# Grant the specific capability the role actually needs
sudo -u postgres psql -c "ALTER ROLE dba_user CREATEDB CREATEROLE;"
sudo -u postgres psql -c "GRANT pg_read_all_data TO reporting_user;"
# Then remove superuser
sudo -u postgres psql -c "ALTER ROLE dba_user NOSUPERUSER;"CREATEDB, CREATEROLE and the predefined pg_read_all_data role for exactly those, and each leaves row-level security and object ownership intact.Check 6: Does PostgreSQL accept connections from any address?
The host-based authentication file is where PostgreSQL decides which networks may connect at all, before any password is checked. An entry with an address of 0.0.0.0/0, ::/0 or the literal all is the PostgreSQL equivalent of the MySQL % wildcard from check 1.
The audit resolves the live file with SHOW hba_file, strips comments, and counts lines whose type starts with host and whose address column is one of those three values.
How to check manually
HBA=$(sudo -u postgres psql -X -tAc 'SHOW hba_file')
sudo grep -vE '^[[:space:]]*(#|$)' "$HBA" \
| awk '$1 ~ /^host/ && ($4=="0.0.0.0/0" || $4=="::/0" || $4=="all")'| Result | When | What it means |
|---|---|---|
| PASS | Every host entry is scoped to a specific address or network | Nothing accepts a connection attempt from an arbitrary address. |
| WARN | One or more host entries use an any-address value | The count is reported. Scope each to the application server subnet. |
| SKIP | PostgreSQL is not accessible | Not installed, server down, or root was unavailable. |
Note that this looks at the address column, while check 5 of the authentication audit looks at the method column of the same file. They are separate findings on the same lines: an entry can be perfectly scoped and still use trust, or use scram-sha-256 and still be open to the world. Read both.
How to fix it
# Before
host all all 0.0.0.0/0 scram-sha-256
# After
host appdb appuser 10.0.1.5/32 scram-sha-256
host appdb appuser 10.0.1.0/24 scram-sha-256
sudo systemctl reload postgresqlWhat this audit does not cover
Being explicit about scope is part of the point of publishing thresholds.
- Whether accounts have passwords at all. That is the authentication audit.
- Whether the port is reachable from outside. That is the network isolation audit. A wildcard host on a database bound to loopback is a much smaller problem than the same account on an exposed one.
- MongoDB and Redis permissions. Both have role and ACL models, but this audit covers MySQL and PostgreSQL, whose privilege tables these checks query directly.
- Table and column level grants. The audit reads global privilege flags such as
File_privandSuper_priv. It does not enumerate what each account can do to each table, so an account with no SUPER andALL PRIVILEGESon every database passes every check here. - Row-level security policies. Whether a policy exists and whether it is correct are application design questions.
- Managed database services. RDS, Cloud SQL and Azure Database do not expose these tables to a host session, and their superuser model is different by design.
The manual audit problem at scale
Running these 6 checks by hand takes 15 to 20 minutes per server, and the queries are not the hard part. The hard part is that the answer is a list of account names, and account names mean nothing without context. Is reporting_user supposed to be a superuser? Was legacy_import created for a migration in 2023 that finished? The query is instant; the judgement is what takes the time.
That is also why this audit degrades faster than the others. Permissions are granted under time pressure, during an incident or a deploy, with the intention of tightening them later. The grant is permanent and the intention is not. Nothing on the server ever objects, because from the database's point of view an over-privileged account is a working account.
A single run tells you the current state. What you actually want to know is what changed since last month, which needs the previous result to compare against. That is the same server management drift problem that compounds across every audit category.
How CtrlOps runs all 6 checks in one click
Instead of running these queries on every server by hand, CtrlOps runs the whole Least-Privilege audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Least-Privilege Permissions
Choose it from the Database category. All 6 checks are listed with their descriptions, severity levels and the estimated run time (about 12 seconds in total). Toggle off any check you want to skip.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table you can sort and filter
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters on this audit in particular: revoking a privilege an application is quietly using breaks it immediately.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. The stored history is what turns a list of account names into a signal, because it tells you which grants are new.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 6 checks | About 2 hours | About 100 seconds |
| Spot which grants are new since last month | Only if you saved the output | Automatic |
| Get a scored report | Not available | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
Conclusion
Least privilege is the layer that decides the blast radius. Every other database audit reduces the chance of a compromise; this one reduces what a compromise is worth. An application account with SELECT, INSERT, UPDATE and DELETE on one schema is a bad afternoon. The same account with FILE and SUPER and a wildcard host is a rebuilt server.
All six checks are MEDIUM because none is an open door on its own, and all six are the kind of thing granted in a hurry and never reviewed. The CtrlOps Security Audit runs the set in about 12 seconds per server, agentlessly.
The neighbouring checklists cover the other database layers: authentication, credential storage, configuration and hardening, network isolation and transport encryption.
Frequently asked questions
It means every account holds exactly the permissions its function requires and nothing more. An application that reads and writes one schema should not have administrative rights, file access, or the ability to connect from any address. The point is blast radius: with least privilege a leaked credential gives an attacker the application's access, not the database server's.
Because none of them is an open door by itself. A wildcard host still requires a password. An extra superuser still requires a credential. What each one changes is how much a successful compromise is worth, which makes them amplifiers rather than entry points. The entry points live in the authentication and network isolation audits, and those carry the HIGH ratings.
Practically never for an application account. FILE lets the account read any file the MySQL process can read and write to any location it can write, which turns a SQL injection into a filesystem read of things like /etc/passwd or your .env. That is why it is the one check here that fails rather than warns: unlike a wildcard host, there is no normal configuration that needs it. If something uses LOAD DATA INFILE, restrict the operation with secure_file_priv instead of granting FILE.
One, the built-in postgres role. Every additional superuser bypasses row-level security, role permissions and object ownership, so it is a full-access credential. For the tasks that usually prompt the grant, use the narrower attributes instead: CREATEDB for creating databases, CREATEROLE for managing users, and the predefined pg_read_all_data role for reporting access.
Empty means unrestricted: LOAD DATA INFILE and SELECT INTO OUTFILE can touch any directory the MySQL process can reach, which is why the audit warns on it. NULL means the operations are disabled entirely, which passes. A directory path also passes and confines them to that path. On most servers NULL is the right answer, because almost no normal application traffic uses these statements.
Because an empty value and a failed query look identical if you only capture the value, and reporting a failed query as "unrestricted file access" would be a false finding on every server where the query did not run. The script keeps the whole result row and checks whether the variable name came back at all, so a read failure reports SKIP and an actual empty value reports WARN.
Because they are packaged system accounts that legitimately hold elevated privileges, and flagging them would mean every MySQL server on earth fails these checks permanently. The excluded set is root, mysql, mysql.sys, mysql.session, mysql.infoschema, mariadb.sys, debian-sys-maint and percona.telemetry. Anything outside that list holding FILE or SUPER is reported, including accounts you created yourself.
In MySQL, create the account against a specific address, 'appuser'@'10.0.1.5', rather than the % wildcard, and use a subnet such as 10.0.1.% where the application tier has no fixed address. In PostgreSQL, replace 0.0.0.0/0 in the host rules file with the actual subnet. Add the scoped account or rule before removing the broad one, so the application never loses its connection mid-change.
No, and this is the main limit worth knowing. These checks read global privilege flags: wildcard hosts, File_priv, Super_priv, superuser roles, host rules. They do not enumerate table and column grants, so an account with no SUPER that holds ALL PRIVILEGES ON *.* passes every check on this page. Review SHOW GRANTS per account alongside this audit.
It opens one administrative connection per engine over your existing SSH session, then queries mysql.user for wildcard hosts, File_priv and Super_priv, reads secure_file_priv and local_infile, queries pg_roles for superusers, and parses the live PostgreSQL host rules file for any-address entries. Every check is read-only and the whole audit takes about 12 seconds.
It reads privilege tables on MySQL and PostgreSQL instances you administer. It does not cover MongoDB or Redis permission models, managed services such as RDS or Cloud SQL where the privilege model is the provider's, Kubernetes RBAC, or application-level role mapping such as Django permissions. For managed databases, use the IAM and access control tooling in the provider console.