SQL Policy¶
Datus Agent does not authenticate users or decide their permissions. A trusted upstream service authenticates and authorizes the request, then sends execution-only inputs as policy_context. Active policy plugins interpret that context and protect reads.
Configuration¶
SQL policy is configured only as a plugin profile; the former agent.sql_policy provider configuration is not supported.
agent:
plugins:
sql-policy:
default:
default: true
policies:
- name: store_scope_sql
type: row_filter
applies_to:
datasources: ["warehouse"]
tables: ["orders", "store_sales"]
condition:
column: store_id
operator: in
value_from: policy_context.row_filter.store_ids
Policy types and their fields belong to the policy plugin. Agent only loads the plugin runtime declared by its datus-plugin.yml manifest.
Request Context¶
The default API provider reads a JSON object from X-Datus-Policy-Context:
The agreed shape has one section per policy family, without an identity or groups layer:
{
"row_filter": {
"access_mode": "scoped",
"store_ids": [1, 2]
},
"column_mask": {
"customers.email": {"strategy": "email_partial"}
}
}
The current sql-policy plugin supports these row-filter modes:
access_mode |
Behavior |
|---|---|
denied |
Reject every data read. |
scoped |
Apply configured row filters and resolve their policy_context.* inputs. Missing inputs fail closed. |
unrestricted |
Skip row filtering. Other policy families, such as future column masking, still run. |
When row policies are configured, a missing or unknown access_mode is rejected. When no row policy is configured, an empty context is allowed.
X-Datus-User-Id remains session identity only. Agent does not merge it into policy_context, and it does not treat any context field as authenticated identity.
Runtime Flow¶
- The upstream service authenticates and authorizes the caller and builds
policy_context. - The API parses the header into
AppContext.policy_contextand validates it through active policy runtimes before starting any built-in or user-defined subagent. - The request-specific
AgentConfigclone carries the same context to every subagent and tool transformer. DBFuncTool.execute_read_enforcedvalidates the original SQL, callsbefore_sql_read, revalidates rewritten SQL, then executes it.- A successful raw result passes through
after_read_resultbefore compression, artifact storage, rendering, or return to the caller. This is the extension point for column masking. - Semantic metric tools call the same plugin runtime before aggregation to add their
wherepredicates.
Invalid runtime declarations, malformed decisions, policy exceptions, denials, and unsafe SQL rewrites all fail closed. Proxied tools execute outside the Agent process and therefore must be protected by the external executor.
For manual checks, pass the same object explicitly:
datus sql-policy check --sql "SELECT * FROM orders" \
--policy-context '{"row_filter":{"access_mode":"scoped","store_ids":[1,2]}}'
Hard Read-Only Switch (agent.sql_read_only)¶
agent.sql_read_only is a separate, simpler mechanism. Do not confuse it with the policy plugins above.
When true, no SQL entry point served by this configuration may run a non-read statement:
DBFuncTool.execute_sql— the tool exposed to agentic nodes and to the MCP server — hard-rejects anything that is not SELECT / SHOW / DESCRIBE / EXPLAIN. This holds regardless of the permission profile, and applies even wherePermissionHooksare bypassed entirely (LLM validators run withhooks=None, and the MCP server's tool instances never see hooks at all).- The tool's other write paths refuse too, not just the
execute_sqldispatcher:execute_write,execute_ddl, andtransfer_query_result. The last one matters most — it reads from one datasource and writes to another (CREATE TABLE/TRUNCATE/INSERT),gen_jobmounts it as a tool of its own, and it never passes throughexecute_sql. - The workflow pipeline refuses too. Its
execute_sqlnode and the output tool's revised-SQL check hand SQL straight to the connector without going throughDBFuncTool, so neither is covered by the gates above — andPOST /workflows/runmakes that pipeline reachable over the API. Both consult the switch through the shareddeployment_read_only_refusalhelper. EXPLAINis only a read when what it explains is.EXPLAIN ANALYZE <write>runs the write on PostgreSQL and MySQL, so the explained statement is classified on its own and refused if it is not itself a read — with or withoutANALYZE, since deciding on the option keyword would mean tracking every dialect's spelling and failing open when one is missed.- A statement that reads at the root can still write inside it: PostgreSQL's data-modifying CTEs (
WITH d AS (DELETE ... RETURNING *) SELECT * FROM d) are a SELECT on the outside. The whole statement is walked for write nodes, not just its root. POST /sql/executerefuses anything that is not a single read statement. Multi-statement input (SELECT 1; DROP TABLE t), writablePRAGMAs,USE/SET, and statements the parser cannot classify are all rejected — the check is fail-closed. Use the request'sdatabase_namefield instead ofUSEto target a database.
DBFuncTool.read_only reports the effective posture, so a tool built with no read_only argument — which is how the MCP server's create_dynamic / create_static factories build theirs — still reads True on a hardened deployment.
How it differs from the policy plugins:
agent.sql_read_only |
policy plugins | |
|---|---|---|
| Needs a plugin | No | Yes |
| Needs request context | No | Yes — policy_context per request |
| What it does | Refuses the statement outright | Rewrites or denies per request context |
| Granularity | All-or-nothing, deployment-wide | Row / table / column, per caller |
Covers POST /sql/execute |
Yes | No (read tools only) |
The switch can only tighten. It is exposed as a read-only property plus a one-way AgentConfig.harden_sql_read_only(): a per-request configuration clone may harden itself, but nothing downstream can turn a true back off — and it never relaxes a component that already runs read-only (Explore, ask_report, the LLM validators).
Use it when the process runs third-party-authored agent content — skills, subagents, reference templates — against datasources it owns. The two mechanisms compose: sql_read_only bounds what kind of statement may run at all; a policy plugin bounds what a given caller may read.
Verifying a deployment¶
Automated coverage lives in tests/unit_tests/tools/func_tool/test_database.py, tests/integration/tools/test_func_tools_db.py and tests/integration/tools/test_mcp_server.py.
Two manual scripts probe a real server end to end over MCP. Neither runs in CI; both print a verdict table and exit non-zero on failure:
| Script | What it checks |
|---|---|
scripts/e2e_sql_read_only_mcp.py |
Self-contained. Stages a throwaway SQLite workspace, runs the flag-on / flag-off matrix, and reports which statements the switch refused. No arguments. |
scripts/e2e_sql_read_only_mcp_project.py |
Points at a real packaged project (--project). --sqlite-standin substitutes a throwaway SQLite datasource so writes really execute when the flag is off; --endpoint probes an already-running server; --live-writes writes to the real datasource (scratch tables only) and --dry-run prints the statements without running them. |