Hardening the database NLQueries reads
NLQueries turns a natural-language question into SQL with a language model, and then runs that SQL against your database. The application checks the statement first, but a validator is a program with bugs in it, and the model's output is not something anyone controls. The only boundary that does not depend on either of those being correct is the database's own permission system.
So: give NLQueries a login that cannot write, and cannot reach anything you would not put in an answer. Everything below is what that means concretely for each supported engine.
What NLQueries already does, and what it does not
The PostgreSQL connector opens every query in a read-only transaction
(SET TRANSACTION READ ONLY); what the other engines do is not the same
everywhere, and What each connector enforces
below is the per-engine account. PostgreSQL applies this to what a statement
does rather than to how it is written, so it refuses DML and DDL anywhere in the call graph,
including within a function invoked by a SELECT, and refuses sequence
functions by name.
It is not sufficient, and it is important to know exactly where it stops. A read-only transaction still permits:
| Still allowed in a read-only transaction | Why it matters |
|---|---|
pg_advisory_lock(...) |
holds a lock other sessions wait on |
pg_sleep(...) |
ties up a connection; bounded here by statement_timeout, not by the transaction |
pg_read_file(...), pg_ls_dir(...) |
reads files off the database host — if the role has the privilege |
SELECT on any table the role can reach |
read-only is not the same as least-privilege |
Every row of that table is a privilege question, and privileges are granted by you. The rest of this page is how.
SQLite and DuckDB are the exceptions to "the rest is yours to configure". Their
file access is reachable from any SELECT rather than granted by a DBA, so the
connectors close it in code. See SQLite and DuckDB below.
What a generated statement may contain
Before a generated statement reaches any connector, the SQL policy parses it with sqlglot in the connector's dialect and allows only what it lists for that dialect:
- the parts of a read query (
SELECT, joins, CTEs, window functions,CASE, the usual operators) and the functions sqlglot models that compute and do nothing else, innlqueries/sql_constructs.py; - functions sqlglot does not model, by name, in
ALLOWED_ANONYMOUSinnlqueries/sql_policy.py: PostgreSQL'sage, MySQL'snowand SQLite'sjulianday, for example. A call written with a qualifier is judged by the whole name, by this list, whether or not sqlglot models it:some_schema.age(a, b)is not PostgreSQL'sage, and is allowed only ifsome_schema.ageis listed;FROM some_schema.generate_series(1, 3)only ifsome_schema.generate_seriesis.
Anything else is refused, including what a read query has no use for: session
variables and parameters (@@version, @x, $1, ?) and CURRENT_USER. The
statement is also parsed again from the form sqlglot renders it in, and refused
if the policy reads that differently: another number of statements, other
tables, or something it would refuse.
tests/sql_corpus.py holds the analytics queries, per dialect, that the lists
must keep allowing. PostgreSQL, SQLite and DuckDB run theirs in CI and report
which tables each query read; every one must be a table the policy saw.
To see what the policy would refuse among your own queries, without refusing anything:
from nlqueries.sql_policy_report import build_inventory
print(build_inventory(statements, "postgres").render())
The lists name sqlglot's classes, so an upgrade of sqlglot that models a function differently can refuse a query that ran before; the report names the construct that would have to be listed.
PostgreSQL
1. A role that owns nothing and cannot write
-- A group that carries the read-only grants, and a login that inherits them.
-- Separating the two means you can rotate the login without re-granting.
CREATE ROLE nlqueries_readonly;
CREATE ROLE nlqueries LOGIN PASSWORD 'use-a-generated-secret' IN ROLE nlqueries_readonly;
ALTER ROLE nlqueries
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
NOBYPASSRLS matters if you use row-level security: a role with BYPASSRLS
reads every row regardless of policy. NOREPLICATION keeps it away from the
write-ahead log, which contains the writes you just forbade.
2. Connect to one database, and create nothing
REVOKE ALL ON DATABASE mydb FROM PUBLIC;
GRANT CONNECT ON DATABASE mydb TO nlqueries_readonly;
-- No temporary tables: they are writes, and they are a way to stage data.
REVOKE TEMPORARY ON DATABASE mydb FROM nlqueries_readonly, PUBLIC;
-- No objects of its own, anywhere.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
That last line is not about NLQueries. Any role that can create objects in a
schema on the search_path can shadow a function or operator that another
query resolves to — so leaving PUBLIC able to create there undoes work you do
elsewhere.
3. Read exactly what you meant to expose
GRANT USAGE ON SCHEMA analytics TO nlqueries_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO nlqueries_readonly;
-- Tables created tomorrow, too. Without this the grant silently rots.
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics
GRANT SELECT ON TABLES TO nlqueries_readonly;
Grant per schema, not per database. If a schema holds something that should
never appear in an answer, the cheapest control is not granting USAGE on it.
4. Sequences, functions, and the file-reading roles
-- Sequences: read-only transactions already refuse nextval(), but the grant
-- should not be there either.
REVOKE ALL ON ALL SEQUENCES IN SCHEMA analytics FROM nlqueries_readonly, PUBLIC;
-- Functions default to EXECUTE for PUBLIC. That includes any function a
-- colleague adds later, whatever it does.
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA analytics FROM PUBLIC;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics
REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
Then check the predefined roles, which are the ones that turn a SELECT into a
file read:
-- Should return no rows.
SELECT r.rolname
FROM pg_auth_members m
JOIN pg_roles r ON r.oid = m.roleid
JOIN pg_roles g ON g.oid = m.member
WHERE g.rolname = 'nlqueries'
AND r.rolname IN (
'pg_read_server_files',
'pg_write_server_files',
'pg_execute_server_program',
'pg_read_all_data'
);
pg_read_all_data is the one people are surprised by: it is read-only, sounds
harmless, and defeats every per-schema grant above.
5. A search path that cannot be shadowed, and budgets
ALTER ROLE nlqueries SET search_path = pg_catalog, analytics;
ALTER ROLE nlqueries SET statement_timeout = '30s';
ALTER ROLE nlqueries SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE nlqueries SET default_transaction_read_only = on;
No $user and no writable schema in the path. default_transaction_read_only
belongs here as well as in the application: it is the same control, held one
layer down, where an application bug cannot skip it.
6. Check what you built
-- Connect as nlqueries, then:
SELECT current_user, current_setting('transaction_read_only'); -- expect: on
SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls
FROM pg_roles WHERE rolname = current_user; -- expect: all false
CREATE TABLE should_fail (x int); -- expect: error
If the third line succeeds, nothing above is in effect.
NLQueries also checks this for you. Each time the pool opens a connection,
the connector reads the login's privileges and records what it finds. The result
appears in nlqueries health:
[OK] Database (shop) identity: nlqueries@shop: least privilege
[WARN] Database (shop) identity: postgres@shop: connects as a superuser, which
bypasses every privilege check — see docs/database-hardening.md
[FAIL] Database (shop) identity: identity could not be determined: <reason>
The check reports; it does not refuse a connection. The privileges are yours to grant, and a deployment that is working should not stop working because the application disagrees with a decision you made.
An identity that could not be read is a failure rather than a warning. The check exists to answer the question, and an unknown answer recorded as "fine" is the outcome it is meant to remove.
What it looks for: SUPERUSER, BYPASSRLS, CREATEDB, CREATEROLE,
REPLICATION, and membership of pg_read_server_files,
pg_write_server_files, pg_execute_server_program, pg_read_all_data or
pg_write_all_data. It also reads search_path and
default_transaction_read_only, but does not report or judge either: the health
line names only the login, the database and the findings above. Check those two
yourself with SHOW search_path; and the first query in step 6.
Transport security
This section is about the postgres connector, the only per-vendor connector
that reads ssl_mode and ssl_ca_cert (with ssl_client_cert and
ssl_client_key); the generic connector applies the same settings to libpq
PostgreSQL URLs, below. The others take no
certificate settings: redshift always connects with ssl=True and offers no
mode or CA; mssql passes pymssql an encryption level, which encrypts the
session only when it is require (SQL Server); and connect mysql
sets no TLS options, so its sessions are encrypted only if the server insists.
nlqueries health prints a transport line for postgres and mssql
connectors and for sqlalchemy connectors that settle a mode (see below).
With no ssl_mode set, the postgres connector uses require and so refuses a
server that offers no TLS. An explicit ssl_mode is honoured as given,
including disable, allow and prefer, which permit an unencrypted connection
(nlqueries health reports that). What it verifies beyond encryption depends on
ssl_mode and whether ssl_ca_cert is configured.
Where to set them. nlqueries connect has no TLS options. Register the
connector first, then add the keys by hand to its entry in connectors.yaml (the
path in CONNECTORS_FILE if set, otherwise $NLQ_STATE_DIR/connectors.yaml,
which is ~/.nlqueries/connectors.yaml by default and
/data/nlqueries/connectors.yaml in the Docker image):
postgres:db.internal:shop:
db_type: postgres
# ... the other keys `connect` wrote ...
ssl_ca_cert: /etc/ssl/certs/db-ca.pem
connect tests the connection under the default require before it writes the
entry, so a server that offers no TLS cannot be registered this way. Running
connect again for the same connector replaces its whole entry, so add the
ssl_* keys back afterwards.
Measured against PostgreSQL 16 and libpq, using servers presenting a correct certificate, one naming a different host, one signed by an untrusted CA, and an expired one:
ssl_mode |
untrusted CA | expired | wrong hostname |
|---|---|---|---|
require, no ssl_ca_cert |
accepted | accepted | accepted |
require + ssl_ca_cert |
refused | refused | accepted |
verify-ca + ssl_ca_cert |
refused | refused | accepted |
verify-full + ssl_ca_cert |
refused | refused | refused |
require without a root certificate performs no verification. It encrypts the
connection to whichever server answers, which does not exclude an attacker
positioned between NLQueries and the database.
ssl_ca_cert is not ignored under require: libpq documents that require
with a valid root certificate behaves as verify-ca, and the measurements
confirm it. The remaining difference is hostname verification.
Configure ssl_ca_cert. When you do and set no explicit ssl_mode, the
connector uses verify-full, which is the only setting in the table that
refuses all three. Set ssl_mode explicitly only to select something weaker,
and nlqueries health will report what that leaves unverified:
[OK] Database (shop) transport: ssl_mode 'verify-full': chain and hostname verified
[WARN] Database (shop) transport: ssl_mode is 'require' with no ssl_ca_cert, so the
server's certificate is not verified …
A certificate whose subject does not name the host you connect to will be
refused under verify-full. Connecting by IP address to a certificate issued
for a hostname is the usual cause.
SQL Server
The mssql connector passes pymssql's encryption argument on every
connection, from the encryption key in the connector's entry. Measured
against SQL Server 2022, which offers TLS without forcing it, reading
sys.dm_exec_connections.encrypt_option for the session:
encryption |
pymssql 2.4.3 to 2.4.5 | pymssql 2.3.0 to 2.4.2 |
|---|---|---|
require |
encrypted | not encrypted: the setting is ignored |
request (default) |
not encrypted | not encrypted |
off |
not encrypted | not encrypted |
So request changes nothing for an existing deployment, and only require
encrypts the session; it refuses a server that does not offer TLS. Any other
value is refused when the connector opens. The mssql extra now requires
pymssql 2.4.3. With an older pymssql installed, the connector refuses
require, since it would connect unencrypted, and nlqueries health reports
that the setting is not applied. Whether the server's certificate is verified
is FreeTDS configuration (a ca file), which the connector does not set and
cannot read back; under require it accepted a self-signed certificate. So
nlqueries health reports every level as a warning:
[WARN] Database (sql) transport: encryption is 'require'; the server's certificate
is verified only if FreeTDS is configured with a CA file …
Set require once the server is known to offer TLS, or have the server force
encryption. connect writes no encryption key, so add it to the entry by hand
as for the ssl_* keys above.
The generic connector
Everything above describes the postgres connector, which takes ssl_mode and
ssl_ca_cert as configuration. The generic sqlalchemy connector takes a whole
SQLAlchemy URL instead, and its posture comes from that URL.
It applies ssl_mode, ssl_ca_cert, ssl_client_cert and ssl_client_key when
the URL names a libpq-based PostgreSQL driver — postgresql://,
postgresql+psycopg2:// or postgresql+psycopg:// — because those are the
drivers whose parameter names map onto these settings directly.
For any other driver it refuses to connect rather than ignoring them:
SQLAlchemyConnector cannot apply ['ssl_ca_cert'] to a 'mysql+pymysql' URL:
the TLS parameter names are driver-specific and only the libpq-based
PostgreSQL drivers are mapped. Put the equivalent settings in the URL's
query string instead.
That is deliberate. Accepting a setting and then not applying it leaves you with
a plaintext session you believe is verified, and no way to tell — which is the
failure the require default elsewhere in this document exists to prevent. Put
the driver's own parameters in the URL query string instead, for example
mysql+pymysql://…/shop?ssl_ca=/etc/ssl/ca.pem.
Where it does apply them it resolves the mode the same way the postgres
connector does, so a root certificate with no ssl_mode selects verify-full
rather than leaving you on libpq's prefer — and the certificate counts whether
you supplied it in the credentials or in the URL's query string.
It only resolves a mode when you configure at least one ssl_* setting on the
connector. A URL alone does not trigger it. So
postgresql://…/shop?sslrootcert=/etc/ssl/ca.pem with no ssl_* settings beside
it connects under libpq's prefer — which falls back to plaintext and verifies
nothing — even though you supplied a CA, and nlqueries health reports nothing,
because the connector did not choose that posture and will not guess at it. Put
sslmode=verify-full in the same query string, or set ssl_ca_cert on the
connector instead of in the URL.
Set each parameter in one place. SQLAlchemy lets connect_args overrule the
URL's own query parameters, so a URL saying ?sslmode=verify-full beside a
connector setting of ssl_mode: require would connect as require with the
stricter setting discarded and nothing said. Rather than pick a winner, the
connector refuses:
SQLAlchemyConnector will not silently overrule the URL: both it and this
connector's TLS credentials set ['sslmode'], and SQLAlchemy would let the
credentials win without saying so. Configure each setting in one place —
either the URL's query string or the ssl_* credentials.
Splitting them across the two is fine, because nothing is then overruled:
?sslrootcert=/etc/ssl/ca.pem in the URL alongside ssl_mode: verify-full in
the connector's settings connects as you would expect.
nlqueries health reports the posture for a sqlalchemy connector exactly as
for a postgres one, reading the URL's TLS parameters and the connector's
ssl_* settings together — so a split configuration is described by what is
actually in force rather than by half of it.
It reports nothing only when nothing on either side sets a mode. libpq then
applies its own prefer default, which is not the default the connector would
have chosen, and naming a mode the connection is not running under would be
worse than saying nothing.
A sqlalchemy URL with no TLS settings configured alongside it is untouched:
the URL alone decides, including its defaults. For PostgreSQL that default is
libpq's prefer, which falls back to plaintext silently — so prefer the
postgres connector, which defaults to require, unless you have a reason not
to.
Row-level security
If tenants share tables, RLS is the mechanism. Two caveats apply.
A table's owner bypasses its own policies unless the table is set to FORCE
ROW LEVEL SECURITY, so the NLQueries role must not own the tables it reads.
And a policy keyed on a session variable that submitted SQL can itself change is
not a boundary. Prefer a per-tenant login, or set the value in a connection hook
the query cannot reach, and reset pooled connections on return.
SQLite
The connector opens the database read-only and installs an authorizer.
Neither can be disabled through configuration. Read-only alone is not
sufficient: measured on SQLite 3.50.4, a connection opened mode=ro will still
ATTACH another database file and read its contents. Read-only restricts what
may be written, not which files may be opened.
| before | after | |
|---|---|---|
INSERT, UPDATE, CREATE, DROP |
wrote to your database | refused (mode=ro) |
ATTACH '/some/other.db' |
opened it and read its rows | refused (authorizer) |
PRAGMA writable_schema, temp_store, database_list |
allowed | refused (allow-list) |
PRAGMA table_info, foreign_key_list |
allowed | allowed — the schema needs them |
load_extension() |
already refused by Python's sqlite3 |
refused |
ordinary SELECT |
worked | works |
Pragmas are allow-listed rather than deny-listed, because those requiring restriction are not confined to an obvious set.
mode=ro is supplied as a URI parameter, so the database path requires care: a
database credential ending ?mode=rwc& would turn a URI built by string
concatenation into one carrying two mode parameters, of which SQLite applies
the first. The path is percent-encoded, so such a value remains part of the
filename.
A path that does not exist is refused rather than created, for the same reason as DuckDB: an empty database answers every question with "no tables", which is a misconfiguration reported as a success.
Still worth doing yourself:
- make the file and its directory read-only at the filesystem level, because a read-only handle is an application-level promise and file permissions are not.
:memory: is not a substitute for a missing file: it succeeds, returns answers
about no data, and presents as working. It is also the one case not opened
read-only, since SQLite does not permit it, but the authorizer still applies
because ATTACH reaches the filesystem from an in-memory database.
DuckDB
DuckDB reads the local filesystem through table functions — read_csv_auto(),
read_text(), glob() — so a file read appears within an ordinary SELECT,
and the database file does not bound what a query may reach. Opening the
database read-only does not restrict them.
The connector closes this, and no setting reopens it. Every DuckDB
connection is made with external access disabled, extension autoinstall and
autoload disabled, and the configuration locked so that no subsequent SET can
alter it. A database on disk is opened read-only; :memory: cannot be, as
DuckDB does not permit it, and does not require it.
Measured against DuckDB 1.5.5, before and after:
| before | after | |
|---|---|---|
read_csv_auto(), read_text(), read_blob() |
read any file | refused |
glob() |
listed any directory | refused |
ATTACH '/some/other.duckdb' |
opened it | refused |
COPY (...) TO '/path' |
wrote a file | refused |
INSTALL httpfs |
reached the network | refused |
CREATE TABLE ... |
wrote to the database | refused (read-only) |
ordinary SELECT against your tables |
worked | works |
Two consequences to note before upgrading:
- A database whose tables are views over external parquet or CSV files will no longer function, since reading those files is what is being refused. Materialise that data into the DuckDB file. This is a deliberate trade-off.
- A path that does not exist is refused rather than created. DuckDB would otherwise create an empty database, so a misconfiguration would present as success.
Spills go to a private directory. A query too large for memory spills to
temp_directory. DuckDB's default puts it beside the database file
(<database>.tmp), so intermediate results, which are customer data, were
written wherever the database lived, or could not be written at all when that
location was read-only. The connector sets temp_directory at connect time to a
directory it creates once per process under the system temporary directory, with
mode 0700, and removes it when the process exits. It is set before the
configuration is locked, so a query cannot move it.
Running DuckDB in an environment with nothing else to read — a container with only the database file mounted, no host secrets and no egress — remains worthwhile. The sandbox above is defence in depth and does not replace it.
The SQL policy refuses file reads too. Before a generated statement reaches
any connector, the policy refuses the file-reading functions sqlglot models
(read_csv, read_parquet, and Snowflake's DIRECTORY and TO_FILE) and a
string literal used as a table name (FROM 'orders.csv', which DuckDB reads as
a file). The functions sqlglot does not model, read_csv_auto and glob among
them, were already refused as unrecognised. If the installed sqlglot records no
token positions, the policy cannot tell a string table name from a quoted one,
and refuses any statement with both a string and a table name.
What each connector enforces
The read-only transaction described above is a PostgreSQL mechanism, and it is not available on every engine. Where it is not, the connector does the next best thing its engine allows -- usually running the query in a transaction it never commits -- and the table records what the connector does, not what the engine could support.
Those are not equivalent guarantees. Refusing a write and undoing one look the same from the outside and are not the same thing: a rollback still ran the statement, and anything it did outside the transaction stands.
| dialect | read-only mechanism | statement timeout | verified in this repository |
|---|---|---|---|
postgres |
SET TRANSACTION READ ONLY |
SET LOCAL statement_timeout |
yes |
sqlite |
mode=ro + authorizer |
watchdog interrupt() |
yes |
duckdb |
read_only=True + locked sandbox |
watchdog | yes |
mssql |
never committed; rolled back either way. T-SQL DDL is transactional, so a DROP is undone too |
none per query | no — asserted against a fake driver |
redshift |
SET TRANSACTION READ ONLY |
SET statement_timeout |
measured by hand |
snowflake |
BEGIN … ROLLBACK, so DML is undone. DDL is not transactional and still stands |
STATEMENT_QUEUED_TIMEOUT_IN_SECONDS (SNOWFLAKE_QUEUE_TIMEOUT_SECONDS, at most half the budget) and STATEMENT_TIMEOUT_IN_SECONDS (the rest) statement parameters |
no — asserted against a fake driver |
bigquery |
none | job_timeout_ms |
no |
sqlalchemy |
DML never committed; rolled back either way, plus SET TRANSACTION READ ONLY where the dialect is postgresql or redshift and START TRANSACTION READ ONLY where it is mysql or mariadb. DDL is not covered everywhere — MySQL, MariaDB and Oracle commit implicitly around it, so a CREATE or DROP stands |
best-effort per dialect | MySQL and MariaDB measured by hand (MySQL 8.0 and 8.4, MariaDB 10.11); otherwise asserted against a fake driver |
nlqueries health reports this per connector, so the row that applies to a
deployment does not have to be looked up here.
Where the mechanism is "none", the only thing preventing a write is the privilege granted to the login. That is BigQuery, and for it the sections above are not defence in depth — they are the whole defence.
Three entries deserve particular attention.
Snowflake and Redshift queries on one connector run one at a time. A Snowflake
transaction belongs to the session rather than the cursor, and the connector
holds a single connection for its lifetime while connector instances are cached
and callers arrive on threads. Two overlapping queries would otherwise share one
transaction, and the first ROLLBACK would end it for both — leaving the other
running under autocommit with nothing left to undo. The BEGIN … ROLLBACK
span is therefore serialised per connector. A shared session cannot offer both
concurrency and a per-query transaction, and a guard that stops holding under
load is worth less than the throughput it would buy.
Redshift is the same: its connector also holds one connection, and a second
query's SET TRANSACTION READ ONLY sent inside the first one's transaction is
refused, while the first one's rollback would end a transaction the second was
still reading in. Its SET TRANSACTION READ ONLY … rollback span is serialised
per connector in the same way.
The wait is bounded, so serialising cannot turn one stuck query into a stuck
connector: a caller waits for the statement timeout plus a margin, or five
minutes where no statement timeout applies, and then fails with an error saying
the connector was busy. CONNECTOR_STATEMENT_TIMEOUT_SECONDS=0 bounds the
query but not the queue behind it, and those waiters occupy pool threads.
Snowflake's DDL survives the rollback. The transaction undoes an INSERT; it
does not undo a CREATE TABLE. If the role can create objects, it can create them
through a query, so the grant is carrying more of the boundary here than anywhere
else.
BigQuery has nothing to undo with. A single query job has no transaction. The
connector pins the job to standard SQL with no session and logs a statement type
other than SELECT at warning, but the job has already run by the time the type
is readable — an audit signal, not a control. Setting BIGQUERY_MAX_BYTES_BILLED
puts a cost cap on every job that answers a question: BigQuery fails a job that
would bill more before running it (configuration.md).
MySQL and MariaDB open a read-only transaction. SQLAlchemy's MySQL dialects
send no BEGIN of their own; the server opens a transaction implicitly on the
first statement. So the connector's first statement is
START TRANSACTION READ ONLY, and the rollback after the query ends it. Inside
it the server refuses a write to any table with error 1792, MyISAM and MEMORY
tables included, which the rollback alone could not undo. The session-scoped
SET SESSION TRANSACTION READ ONLY is not used, because it would outlive the
query on a pooled connection. The statement timeout is set after
START TRANSACTION, which commits implicitly, so it is in force for the query.
Measured by hand with PyMySQL against MySQL 8.0.46, MySQL 8.4.11 and MariaDB
10.11.14, before and after, with the same results on all three:
| before | after | |
|---|---|---|
INSERT into an InnoDB table |
rolled back | refused (1792) |
INSERT into a MyISAM table |
kept | refused (1792) |
SELECT 1; SELECT 2 with client_flag=65536 in the URL |
both ran | refused |
LOAD DATA LOCAL INFILE with local_infile=1 in the URL |
file uploaded | refused by the client |
CREATE TABLE |
kept | kept (implicit commit) |
| a query over its statement timeout | cancelled | cancelled |
a plain INSERT on the same pooled connection afterwards |
committed | committed |
tests/integration/test_mysql_integration.py repeats this against a server you
name with NLQ_TEST_MYSQL_URL; it is skipped otherwise. The URL hardening behind
the two middle rows is described in
connectors.md.
Oracle's gap looks closable. It has a transaction-scoped
SET TRANSACTION READ ONLY that must be the first statement of the transaction
-- exactly where the connector applies one -- and refuses DML and DDL thereafter
with ORA-01456. It is left out of _apply_read_only only because no test here
can reach an Oracle instance, and adding a statement to the execution path of an
untested engine is how every query against it becomes an error instead. Someone
with an instance should try it and move Oracle out of the column below.
And the rollback does not reach their DDL. MySQL, MariaDB and Oracle commit
implicitly around DDL, so a CREATE or DROP sent through this connector is
permanent whatever the transaction does — on MySQL and MariaDB the implicit
commit ends the read-only transaction before the DDL runs. It is the same limit
Snowflake has, for the same reason, and it applies to any engine behind the
generic SQLAlchemy connector that commits implicitly. On all of these the grant is the only thing
standing between a generated statement and a schema change, which is why the
role in this document is the boundary and the rollback is not.
Redshift enforces both, but no test here reaches a cluster. CI cannot
provision one, so the mechanisms were measured by hand against Redshift
Serverless: a write is refused with SQLSTATE 25006 (transaction is read-only)
and a query over its budget is cancelled with SQLSTATE 57014. A read-only user
and a WLM query-monitoring rule remain worth having — the read-only transaction
restricts what a statement may do, not what the login may reach.
The generic sqlalchemy connector reaches any engine SQLAlchemy supports,
so no single statement about its behaviour holds beyond the rollback, which does.
It adds SET TRANSACTION READ ONLY when the URL turns out to be postgresql or
redshift, START TRANSACTION READ ONLY for mysql or mariadb, and nothing
further for a dialect it does not recognise. It refuses sqlite:// and
duckdb:// URLs outright: the dedicated connectors open those files read-only
and sandboxed, and through this one the same file opened read-write, with
ATTACH and DuckDB's file functions available to a query.
"Verified in this repository" means the mechanism is exercised by a test against a real engine. Snowflake, BigQuery, Redshift, SQL Server and MySQL require accounts or servers a test run cannot provision, so they are recorded as unverified whatever their documentation says — their tests assert the statements sent to a fake driver, which shows the connector asks for the right thing and not that the engine honours it.
What to grant, per vendor
The same shape, in each vendor's vocabulary:
- a dedicated identity that owns nothing,
SELECTon an explicit list of schemas or datasets and nothing wider,- no ability to create, load, copy, or call arbitrary functions,
- a statement timeout and a cost or slot cap,
- and no membership in any role that reads the host filesystem or all data.
What nlqueries health checks on other engines
The identity line described for PostgreSQL in step 6 is also printed for MySQL
and MariaDB (through the sqlalchemy connector), SQL Server and Snowflake. In
each case it names the login and anything it holds beyond reading; a login that
holds nothing more is least privilege, and a check that could not read the
grants is a failure, not a pass.
| Engine | Read from | Reported |
|---|---|---|
| MySQL, MariaDB | SHOW GRANTS |
any privilege other than SELECT, SHOW VIEW and USAGE, with the scope it applies to (INSERT, DROP, FILE, SUPER, ALL PRIVILEGES, a dynamic *_ADMIN privilege, EXECUTE...); WITH GRANT OPTION; and any role granted, since a role's privileges are not expanded |
| SQL Server | role membership and fn_my_permissions for the database and the server |
membership of any server role but public, and of any database role but public and db_datareader; any effective database permission beyond CONNECT, SELECT, VIEW DEFINITION, VIEW DATABASE STATE, VIEW DATABASE PERFORMANCE STATE, SHOWPLAN and REFERENCES, the two column-key VIEW permissions public holds in every database, and SQL Server 2022's VIEW SECURITY DEFINITION, VIEW PERFORMANCE DEFINITION, VIEW CRYPTOGRAPHICALLY SECURED DEFINITION and VIEW DATABASE SECURITY STATE, which those imply; any server permission beyond CONNECT SQL, VIEW ANY DATABASE, VIEW ANY DEFINITION, VIEW SERVER STATE, VIEW SERVER PERFORMANCE STATE and CONNECT ANY DATABASE, and their server counterparts VIEW ANY SECURITY DEFINITION, VIEW ANY PERFORMANCE DEFINITION, VIEW ANY CRYPTOGRAPHICALLY SECURED DEFINITION and VIEW SERVER SECURITY STATE. Effective permissions include what a custom role or a direct grant gives |
| Snowflake | SHOW GRANTS TO ROLE for the current role and every role it inherits, up to 50 |
ACCOUNTADMIN, ORGADMIN, SECURITYADMIN, SYSADMIN or USERADMIN in reach, and any privilege other than USAGE, SELECT, REFERENCES, MONITOR, OPERATE, READ and IMPORTED PRIVILEGES, counted by privilege and object type. Secondary roles are not followed |
MySQL and SQL Server are read each time the pool opens a connection, as
PostgreSQL is. Snowflake is read the first time health asks, since walking the
role hierarchy is one SHOW GRANTS per role. As for PostgreSQL, the check reports
and does not refuse a connection.
On SQL Server the server scope is read apart from the database's. Where it cannot
be read (Azure SQL may not offer it from a user database), the line reports the
database and names what it could not read, (not read: server permissions ...),
as a warning rather than a failure or a pass. A sysadmin login is still caught,
because it is the database owner everywhere and holds CONTROL among the
database's effective permissions; other server roles show only when the server
scope can be read. Grants on a schema or an object (GRANT INSERT ON SCHEMA::dbo
TO app) are not seen: fn_my_permissions is read for the database and the
server, and those grants are held on the schema or the object. So on SQL Server
a clean result reads least privilege at database and server scope; check
schema and object grants in sys.database_permissions.
On Snowflake the check waits up to 30 seconds for a running query to release the
session; if the query is still running after that, the check reports that it
could not be determined, and the next health run reads it again. A database
role granted to the session's role is not expanded, since its grants take
another statement (SHOW GRANTS TO DATABASE ROLE), so it is named as not read
and the line warns.
If you take one thing
Create the role. Every other control here is defence in depth around it, and the application-side ones are written by people who also write the bugs.