SQL Server access control and query approval
SQL Server decides which logins and users can reach which objects. It does not decide whether this particular statement, typed by a person at 3 a.m., should run. QueryProxy adds that decision: developers submit T-SQL, the statement is parsed and checked before it can enter the queue, a DBA approves it, and the result is masked before it is stored. The SQL Server password stays in QueryProxy’s vault and is never handed to a developer.
What do you have to install first?
SQL Server is the one engine the default image cannot reach. The Docker image
ships pdo_pgsql, pdo_mysql and pdo_sqlite. To proxy SQL Server:
- Extend the Dockerfile with Microsoft’s ODBC driver and the
sqlsrvandpdo_sqlsrvPECL extensions. - Build your own image and run it in place of
queryproxy/queryproxy. - Make sure the extensions are in the PHP build that runs both the web process and the queue worker, because approved queries execute on the worker.
On a manual install the same applies: pdo_sqlsrv has to be present for SQL
Server targets. The
configuration guide has the details.
Until this is done, a SQL Server connection cannot run a query.
What does a login’s permission leave open?
Permissions scope a login to databases, schemas and objects, and you should tighten them first. They still leave gaps:
- A user who may
UPDATEa table can run anUPDATEwith noWHERE. - A user who may
SELECTtheuserstable receives every email address in it, unmasked, on a laptop. - Nobody else sees the statement before it runs.
SQL Server also has procedures that leave the database engine. Microsoft’s
documentation says xp_cmdshell
spawns a Windows command shell, and the command runs with the same rights as
the SQL Server service account. It is off by default and enabled with
sp_configure (read on 2026-10-13). QueryProxy blocks both the xp_* family
and sp_configure at submission, so a request cannot even ask.
What database access control is explains
why permissions alone do not close these gaps.
What does QueryProxy block on SQL Server?
Every submission is parsed before it reaches the approval queue, and a request that breaks a guard is rejected at submission with an explicit message. The SQL guards reference lists the rules per dialect. The ones that matter most here:
UPDATEandDELETEwithout aWHERE, or with one that is always true (WHERE 1 = 1), are rejected. A top-levelSELECTwith noTOPgetsTOP (1000), and aTOPabove10000is clamped toTOP 10000. CTE and set-operation queries get noTOP; the 10000-row ceiling bounds them.xp_*,sp_configure,sp_oa*,sp_addlinkedserver,sp_addlinkedsrvlogin,sp_serveroption,sp_addextendedprocandsp_execute_external_scriptare blocked, whether called as a statement or afterEXEC.OPENROWSET,OPENDATASOURCE,OPENQUERYandBULK INSERTare blocked.GRANT,REVOKE,DENYandSHUTDOWNare blocked.- Procedures that run text they are handed, such as
sp_prepare,sp_execute,sp_sqlexec,sp_msforeachtableandsp_send_dbmail, are blocked whatever the arguments. THROW,RECONFIGURE,KILLandCHECKPOINTare classified as writes, so they go through approval.
DELETE FROM orders; -- rejected at submission
DELETE FROM orders WHERE id = 42; -- allowed, still needs a DBA
EXEC xp_cmdshell 'dir'; -- rejected at submission
SELECT 'xp_cmdshell'; -- a plain read
The table names are examples; the verdicts follow the documented rules.
How is dynamic SQL handled?
EXEC('...') and sp_executesql take SQL as text, which is where T-SQL
guards usually break. QueryProxy judges what it can read:
EXEC('DELETE FROM t')without aWHEREis blocked, andEXEC('DELETE FROM t WHERE id = 1')is classified as a write.sp_executesql N'...'is judged by the usual rules on the text inside.EXEC(@sql),EXEC('a' + 'b'),EXEC(...) AT srvandEXEC ... AS USERare blocked, because the guard cannot know what will run.- A nested literal such as
EXEC('EXEC xp_cmdshell ''dir''')is blocked. - Dynamic SQL nested more than 3 levels deep is blocked.
The guard errs on the strict side: EXEC "quoted_name" and WHERE exec = 1 are
blocked as well.
How do you connect a SQL Server database?
After the image change above, a DBA registers the database under
Connections with the SQL Server driver (sqlsrv). The default port is
1433
(DbDriver.php,
v0.2.5). Credentials are encrypted at rest with AES-256, and a developer sees a
connection only after a DBA grants it. The
post on production access without shared passwords
explains why the password should stay in QueryProxy.
For read-only teams, register a separate connection with a login that has
SELECT permission only, and point it at a replica where you can. The
production hardening guide recommends this.
What happens to the result?
Rows are masked while they stream from the cursor to the result file, so an
unmasked copy never exists on disk. Masking rules match a column name (such as
*email*) or the content of a value, and a value that no rule matches comes
back unchanged. Dynamic data masking explains the
idea, and the data masking documentation lists the rule
types and strategies. Every step, from submission to download, is written to an
immutable audit log.
What are the limits on SQL Server?
- The driver is yours to add. The default image has no SQL Server support, as described above.
- The denylist is incomplete. It names dangerous syntax known today, and a spelling it does not name passes. The SQL guards reference says so itself: what authorizes a statement is the DBA approval, not the guard.
EXECUTE AS USER = 'x'is not covered. The guard does nothing about it.- Not yet run against live servers. The reference states that the SQL Server behaviour is verified against the guard’s unit tests, and that this dialect has not been run against a live server.
- No read-only transaction or statement timeout on the database side yet.
Both are listed as planned for v0.3.0. Until then, use a
SELECT-only login for read connections. - Code stored on the server is not covered. A stored procedure that already
exists in the database runs what it contains.
EXEC dbo.my_proc 1is classified as a write and goes to approval. - Masking is rule-based. A value that no rule matches is returned as it is, so QueryProxy does not claim to mask all personal data.
When do you not need QueryProxy?
If one person owns the database and is the only one who connects, there is no second person to approve anything, and permissions plus a read-only login are enough. The same holds for a development database that holds no real data. If your team only needs a better SQL client, see QueryProxy compared with DBeaver and DataGrip. QueryProxy earns its place when people need ad-hoc access to data that matters, and you want a second pair of eyes and a record of what ran.
Read the approval workflow for how a request moves, or compare QueryProxy with a bastion host. PostgreSQL teams can start from PostgreSQL access control.
Frequently asked
- Does QueryProxy support SQL Server out of the box?
- No. The default Docker image ships pdo_pgsql, pdo_mysql and pdo_sqlite. To proxy SQL Server you extend the Dockerfile with Microsoft's ODBC driver and the sqlsrv and pdo_sqlsrv PECL extensions, then run your own image in place of queryproxy/queryproxy. The extensions must be present in the PHP build that runs both the web process and the queue worker.
- Why does the driver have to be on the worker as well as the web process?
- Approved queries execute on the queue worker, not in the web process. If the worker's PHP build lacks the SQL Server extensions, a request can be approved and then fail when it runs.
- Does QueryProxy block xp_cmdshell?
- Yes. Any xp_* procedure, called as a statement or after EXEC, is blocked at submission, together with sp_configure and sp_oa*. A name that only appears inside a string literal, such as SELECT 'xp_cmdshell', is a plain read.
- Can a developer run EXEC with a SQL string built at run time?
- Not when the text cannot be read. EXEC(@sql) and EXEC('a' + 'b') are blocked. EXEC with a literal string is judged by the usual rules, so a DELETE without WHERE inside it is blocked and one with a WHERE is classified as a write.
- How many rows does a SQL Server SELECT return?
- A top-level SELECT without TOP gets TOP (1000); CTE and set-operation queries are bounded by the 10000-row ceiling instead. A TOP above 10000 is clamped to TOP 10000. Forms the guard cannot evaluate, such as TOP (@v) or TOP 10 PERCENT, are blocked.