SQL console in the admin¶
Every serious admin panel grows one of these — phpMyAdmin, Adminer, Metabase's native query, Django SQL Explorer — because eventually someone needs an answer the list view cannot give. This is that console, with the guard rails that make it survivable.
Read this before enabling it
A SQL filter in the application is defence in depth, not a security boundary.
The analyser here parses properly (via sqlglot) rather than matching
strings, and that stops the ordinary accidents: a DROP typed by someone
who only meant to SELECT, an UPDATE with no WHERE, a query against a
table holding card data. It will not stop a determined operator with
time — SQL has CTEs, subqueries, functions, dialect extensions and comment
tricks, and any parser-based allowlist is a game of coverage.
The boundary that actually holds is the database user:
CREATE ROLE admin_console LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE app TO admin_console;
GRANT SELECT ON orders, customers, invoices TO admin_console;
Point the console at that connection, then use the policy below to narrow further and to produce a readable refusal instead of a database error. Used that way the two layers add up. Used alone, the policy is a speed bump.
Turning it on¶
The console is off by default — without sql_shell=, the route does not
exist.
from fastapi import FastAPI
from tempest_fastapi_sdk import AsyncDatabaseManager, UserModelAuthBackend
from tempest_fastapi_sdk.admin import (
AdminSite,
SqlCapability,
SqlShellPolicy,
SqlShellService,
make_admin_router,
)
from src.admin import site
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel
from src.services.audit import record_sql_attempt
auth_backend = UserModelAuthBackend(UserModel)
console_db = AsyncDatabaseManager(settings.READONLY_DATABASE_URL)
app = FastAPI()
console = SqlShellService(
console_db, # the restricted connection, not the app's
policy=SqlShellPolicy(
capabilities={SqlCapability.READ},
denied_tables={"users", "user_tokens"},
max_rows=500,
),
dialect="postgres",
auditor=record_sql_attempt,
)
app.include_router(
make_admin_router(
site,
db=db,
auth_backend=auth_backend,
secret_key=settings.SECRET_KEY,
sql_shell=console,
),
)
A "SQL console" entry appears in the sidebar, under "System".
The policy¶
| Field | Default | What it does |
|---|---|---|
capabilities |
{READ} |
Statement families allowed. |
allowed_tables |
empty (all) | When set, only these tables. |
denied_tables |
empty | Never touchable — deny beats allow. |
max_rows |
1000 |
Rows fetched before truncating. |
max_statements |
1 |
Statements per submission. |
require_where |
True |
Refuses UPDATE/DELETE without WHERE. |
statement_timeout_ms |
10000 |
Server-side timeout where the dialect supports it. |
Capabilities¶
Split the way an operator thinks about risk, not the way SQL groups keywords:
| Capability | Covers |
|---|---|
READ |
SELECT, WITH … SELECT, DESCRIBE, a PRAGMA that only reports — and EXPLAIN / SHOW only under the mysql dialect (see below) |
INSERT |
adds rows |
UPDATE |
changes rows |
DELETE |
removes rows |
DDL |
CREATE, ALTER, COMMENT |
DROP |
DROP and TRUNCATE — irreversible loss |
ADMIN |
GRANT, REVOKE, SET, and anything the analyser cannot classify |
The unknown lands on the most privileged capability
A construct nobody anticipated becomes ADMIN, so it needs the highest
permission rather than slipping through as harmless. It is the safe
default: a parser that does not recognise something must not wave it
through.
EXPLAIN and SHOW are ADMIN on PostgreSQL and SQLite
The classification comes from the tree sqlglot builds. For EXPLAIN
and SHOW under the postgres and sqlite dialects (and with no
dialect), sqlglot 30 has no dedicated expression and returns a
generic Command — which falls to the ADMIN default. Measured with
analyze_sql(sql, dialect=...):
| Statement | postgres |
sqlite |
mysql |
|---|---|---|---|
EXPLAIN SELECT 1 |
admin |
admin |
read |
SHOW TABLES |
admin |
admin |
read |
DESCRIBE users |
read |
read |
read |
On a READ-only console over PostgreSQL or SQLite, then, EXPLAIN is
refused. That is the safe default working — EXPLAIN ANALYZE runs the
statement — not a bug to work around by granting the operator ADMIN.
Reads outside, writes inside
The top-level node is not enough. A data-modifying CTE is a SELECT
at the top; MySQL's EXPLAIN ANALYZE runs the statement it explains;
and a SQLite PRAGMA with a value changes the database. Through
0.302.0 all three classified as read and passed a read-only
console. The analyser now walks the tree and the statement takes the
most privileged capability it contains — and the required-WHERE
rule judges the write, not the SELECT around it. Measured with
analyze_sql(sql, dialect=...):
| Statement | Dialect | Capability |
|---|---|---|
WITH gone AS (DELETE FROM orders WHERE id = 1 RETURNING *) SELECT * FROM gone |
postgres |
delete |
EXPLAIN ANALYZE DELETE FROM orders WHERE id = 1 |
mysql |
delete |
EXPLAIN DELETE FROM orders |
mysql |
read |
PRAGMA table_info(orders) |
sqlite |
read |
PRAGMA journal_mode |
sqlite |
read |
PRAGMA journal_mode = DELETE |
sqlite |
admin |
PRAGMA optimize |
sqlite |
admin |
Deny beats allow¶
from tempest_fastapi_sdk.admin import SqlShellPolicy
policy = SqlShellPolicy(
allowed_tables={"users", "orders"},
denied_tables={"users"},
)
policy.table_allowed("users") # False
policy.table_allowed("orders") # True
Deliberate order: putting a table in denied_tables cannot be undone by a
broad allow rule elsewhere.
Hidden tables are found¶
The analyser walks the whole tree, so subqueries, CTEs and joins count:
from tempest_fastapi_sdk.admin import analyze_sql
analyze_sql("SELECT * FROM orders WHERE id IN (SELECT id FROM secrets)")[0].tables
A policy that only looked at the top-level FROM would miss exactly where
someone hides a table they should not read.
A CTE alias is not a table
WITH recent AS (SELECT * FROM orders) SELECT * FROM recent reports
['orders'], not recent. Otherwise an allowed_tables policy would
refuse a query that only reads what it should.
Multi-statement is refused by default¶
That is how an allowed SELECT carries a DROP past someone skimming the
box. Open it with max_statements if you genuinely need it.
UPDATE/DELETE without WHERE¶
The most common console accident. On by default.
What the operator sees¶
The page shows the policy before you type — capabilities, tables, row cap — so the limits are known rather than discovered through refusals. A console that can write shows a warning.
A refusal and a database error render differently on purpose: "you may not do that" and "your SQL is wrong" lead to different fixes.
Auditing¶
import logging
from src.db.models import SqlAudit
logger = logging.getLogger(__name__)
async def record_sql_attempt(entry: SqlAudit) -> None:
"""Persist every console attempt, allowed or refused."""
logger.warning(
"sql_console",
extra={
"principal": entry.principal,
"allowed": entry.allowed,
"capability": entry.capability,
"tables": entry.tables,
"reason": entry.reason,
"sql": entry.sql,
},
)
Every attempt is audited, including refused ones — a record of what someone tried to run is usually more interesting than the list of what worked.
An auditor failure does not take the console down
By design, so a broken sink cannot remove the tool. But a console you cannot audit is one you should turn off — log inside it.
Reads run in a rolled-back transaction¶
Under a read-only policy, each statement runs inside a transaction that is
rolled back. A SELECT that turns out to mutate — a function with a side
effect, a dialect quirk — leaves nothing behind.
Using it outside the admin¶
The service does not depend on the page:
import asyncio
from tempest_fastapi_sdk.admin import SqlShellDenied, SqlShellPolicy, SqlShellService
from src.api.dependencies.resources import db
policy = SqlShellPolicy(allowed_tables={"users", "orders"})
service = SqlShellService(db, policy=policy, dialect="postgres")
async def main() -> None:
"""Run this example."""
try:
result = await service.execute("SELECT 1", principal="job@internal")
except SqlShellDenied as exc:
print("refused:", exc)
asyncio.run(main())
Checklist before enabling in production¶
- The console points at a restricted role, not the app user.
-
denied_tablescovers credential, token and personal-data tables. -
capabilitiesis the minimum that solves the case — start at{READ}. - The
auditorwrites somewhere you actually read. - Admin access already requires MFA (
[mfa]) for whoever reaches the page. -
statement_timeout_msis set, on a dialect that honours it.
Recap¶
- Off by default; without
sql_shell=the route does not exist. - The policy explains and narrows; the GRANTs enforce. Use both.
- Deny beats allow, and the unknown lands on
ADMIN. - Subqueries and CTEs are inspected; a CTE alias is not a table.
- Every attempt is audited, refusals included.
See also: Admin site for the rest of the panel and Security for what surrounds access to it.