Security Model
Authorization is enforced at planning time, by the logical validator, before any SQL exists. That is the one security property this engine provides. It is not authentication, it is not containment, and it is not row- or column-level security.
The role is supplied by the caller
There is no authentication anywhere in this project. Where the role comes from depends on the client:
- CLI:
nl2sql run --role <id>, defaulting toadmin. - Python API:
UserContext(roles=[...]), whatever the calling code passes. - REST API: the
get_user_contextdependency (packages/api/src/nl2sql_api/auth.py). It reads a header set by a trusted proxy (api.role_headerinnl2sql.yaml) or a static role (api.role), and answersHTTP 401when neither gives one. The body'suser_contextcounts only with the dev flagapi.trust_body_role: true, which logs a warning at startup. See Caller role. The routes that change datasources, LLMs or the index also need one of the roles inpolicies.admin_roles(default[admin]), elseHTTP 403; with no role source configured they answer loopback clients only. See Admin routes.
The REST API still does not verify who the caller is. If you expose this
service, put your own authentication in a proxy in front of it, derive the role
from that, and have the proxy overwrite the role header on every request;
otherwise any client can send the header itself. An empty roles list
denies everything, which is the correct default for an unauthenticated caller.
A role name that is not present in the policy file grants nothing, exactly like
an empty roles list. It grants nothing, so the caller is refused with a normal
SECURITY_VIOLATION, not a crash: through the pipeline the datasource resolver
refuses first (no datasource is reachable), and the validator on its own refuses
every table with its policy check failed. An unknown name listed beside a
known one is ignored, and the known role's permissions still apply.
Hidden tables and strict refusal
Every access decision asks one Scope: what the caller may read in one
datasource, from the roles' allow and deny patterns and, when one is
configured, a policy check's grant (see
Policies). A run builds each datasource's
scope once, so every step decides the same way.
- Denied tables are hidden from the planner. The schema retriever
searches only the tables the scope allows, on both the full-snapshot path and
the vector-retrieval path, so a denied table's name, columns, keys,
relationships, statistics and sample values never reach the prompt, the LLM
provider or the run trace. The answerability judge and the decomposer see
only the readable table names. A question that needs only denied tables gets
"no data for that question" from the planner rather than a refusal. (For
tables the role can read, the prompt carries
sample_valuesand, for a date, time or numeric column, its[min, max]asrange; the other statistics stay in the snapshot.) - A plan naming a hidden table is refused. The logical validator asks the
same scope for every table in the plan and refuses a denied one with
SECURITY_VIOLATIONbefore any SQL exists. The run ends there. - A generic message for the user. The refusal the caller sees is
"You do not have permission to see the data this question requires." It names
no table, because naming one tells an unauthorised user that it exists. The
table, the role and the rule that denied it are logged as a warning and
recorded in the error's
details(datasource_id,table,roles,rule), which the run trace keeps and theQueryResulterror summary does not carry. The validator'spolicycheck is still present withpassed: false, so a UI can show the gate firing. - Naming tables is an opt-in. Set
policies.refusal_names_tables: trueto put the role and table in the user-facing message ("Role 'viewer' denied access to 'chinook.Customer'."). The demo's config (nl2sql init --demo) sets it, because showing the refusal is the demo's point. - The decision is deterministic code. Only the scope decides, from the policy and the plan's table list. Nothing the model writes (its reasoning, a claim that the role is allowed) is read by the policy check, so a crafted prompt cannot turn a refusal into an allow.
This is table-level access control. It is not column masking and not row-level security: a role that may read a table reads every column and every row of it. A downloaded trace of a permitted run holds real rows, and the playground serves any trace by id with no login, so restricting a trace to the user who produced it is left to a multi-user deployment.
Security controls
- Policies: the
policiessection ofnl2sql.yaml, evaluated byPolicyEngineinto aScope. A role allows and denies tables with patterns overdatasource.schema.table(each part a glob, case ignored) — nothing finer. There is no column masking and no row-level security. - Policy check (optional): a plugin that asks an outside service (IAM, OPA, Cedar) what the caller may read, once per caller and datasource per run. Its grant is intersected with the roles, so neither can widen the other, and it fails closed: an exception denies the datasource for the run and is logged with the check's name. See A policy check.
- Logical validation enforces schema constraints and policy-based table access.
- Hooks (optional): your code at six points of every run, on the
generated and the verified path. A hook may change the question, narrow
the datasources or change the plan only before the validator, so a changed
plan is validated (policies included) like any other, and a narrowed list
of datasources is checked against the role again. After the validator a
hook may only stop the run. Hooks fail closed: an exception denies
with
HOOK_FAILEDunless that hook is configuredon_error: open. See Hooks. - Approval (a hook's
NeedsApproval): the engine, not the hook, checks anApprovalon the re-run: the request unedited, the same question and asker, the hash of the SQL generated again, and an approver holding one of the hook's configured roles. AnApprovalis not signed: it is trusted as far as theuser_contextthe caller passes. See Query API → Approval. - A supplied plan (
run_query(plan=...)) skips planning, not checking: its datasource is scope-checked, and the plan passesplan.afterand the logical validator under the caller's role like any other. - Audit logging records
llm.changedevents only: eachengine.llm.replace(the Python API,PUT /api/v1/settings/llm, the playground's model switch), with who made it and each agent's old and new model, never a key. Queries are not audited. See../observability/stack.md. - Bounded execution: a global timeout and a per-run cancellation token cap each run. There is no process sandbox — the engine runs in-process (see
../execution/isolation.md).
What keeps the SQL read-only
The model never writes SQL. It emits a typed plan whose query_type is
Literal["READ"] — pydantic rejects anything else before a node sees the plan,
which is the whole enforcement — and the generator renders that plan with
sqlglot starting from exp.select(). A statement that is not a SELECT therefore cannot be
produced, and free text from the model is never executed.
Below that, the session itself is read-only wherever the database allows it.
There is no setting: the engine never writes, so every built-in adapter makes
each new connection read-only when it can, and declares ENFORCES_READ_ONLY
only where it does:
| Adapter | Read-only session | ENFORCES_READ_ONLY |
|---|---|---|
| PostgreSQL | SET default_transaction_read_only = on |
yes |
| MySQL | SET SESSION TRANSACTION READ ONLY |
yes |
| SQL Server | none: the database has no such session setting | no |
| DuckDB | read_only=True on the file |
yes (not for :memory:) |
| SQLite | mode=ro |
yes |
engine.check() warns about each datasource whose adapter does not declare
it ("this adapter cannot make the session read-only"), and still routes to it.
For those, the login is the third guard: on SQL Server, a login whose only role
is db_datareader (see the adapter). The
executor has no statement-kind or statement-count gate of its own. Grant the
engine a read-only database user anyway: a read-only session stops writes,
not reads of tables you did not mean to expose.
flowchart TD
User[UserContext] --> Scope[PolicyEngine.scope]
Check[PolicyCheck.grant, optional] --> Scope
Scope --> Retriever[SchemaRetrieverNode: hides denied tables]
Scope --> Validator[LogicalValidatorNode: refuses denied tables]
Validator --> Executor[ExecutorNode]
Executor --> Audit[audit observer: storage.path/audit/]
Policy enforcement details
LogicalValidatorNode enforces strict namespacing through the Scope:
- A table is read in a schema: the plan's, or the adapter's default schema.
An allow of
sales.public.ordersdoes not allowsales.audit.orders. Patterns have one part or three; a two-part pattern is rejected when the config loads (see Policies). - Matching ignores case, so a deny cannot be dodged by
Ordersagainstorders. - Deny beats allow within a role; across roles a table is readable when some role allows it and that same role does not deny it.
- If no datasource ID is present, validation fails closed.
- With a policy check, a table must also be allowed, and not denied, by the check's grant; a check that failed allows nothing in that datasource.
- An unknown role, or no role, allows nothing and is refused like any denial.
- One
SECURITY_VIOLATIONper forbidden table, with the generic message unlesspolicies.refusal_names_tablesis set, anddetailsnaming the table, the roles and the rule.
Validation gates
Validation rules are enforced by LogicalValidatorNode and documented in ../architecture/invariants.md and ../architecture/nodes/logical_validator_node.md.
Source references
- Policies:
packages/nl2sql/src/nl2sql/policies/(scope.py,patterns.py,check.py) - The policy check contract:
packages/sdk/src/nl2sql_sdk/policy.py - Validator node:
packages/nl2sql/src/nl2sql/pipeline/nodes/validator/node.py - Hiding denied tables:
packages/nl2sql/src/nl2sql/pipeline/nodes/schema_retriever/node.py - The audit log:
packages/nl2sql/src/nl2sql/observers/audit.py(see Observers)