Blog 2026-09-20

Read-only SQL is not a data-access policy

A database connection can reject every write and still reveal information it should not. “The agent cannot change anything” answers one question. “Which data can the agent read?” is a different question.

Draxlr’s Chat With Your SQL Data Using Claude via Draxlr MCP (+ Per Customer Access) makes that distinction a useful starting point. Its published description covers both read-only SQL and per-customer restrictions, including two stores asking the same question and receiving different results. This article draws on that description, not a reviewed transcript.

The walkthrough below is an independent Postgres example. It does not reproduce Draxlr’s permission system, imply a Bufflehead integration, or claim the creator’s endorsement. Bufflehead provides read-only database access; the database administrator separately defines what its connection may read.

Two controls, two different jobs

  • Read-only operation: keep the workflow from changing stored data.
  • Read scope: limit which tables, columns, and rows the connection can retrieve.

Consider an assistant that answers questions about order totals. It needs order amounts, not customer email addresses or internal support notes. A credential with SELECT access to every table still has too much access for that task, even if it has no write privileges.

Asking the model not to query sensitive tables is useful guidance, but it is not enforcement. The same applies to hiding a table from a suggested schema list. The database should reject an out-of-scope query even when the exact table name is supplied.

Build a small, testable Postgres boundary

This example uses fictional data in a new, disposable database named ai_scope_demo. An administrator creates that database first and runs the setup below through an administrative SQL client, not through Bufflehead. Use a fresh role and schema; these statements are not a migration for an existing production database.

The ai_order_reader login starts with no role memberships, owns none of the demo objects, and receives read access to one table. Authentication must be configured separately through your normal database administration process; no password belongs in the article or an AI prompt.

CREATE ROLE ai_order_reader
  LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE
  NOINHERIT NOREPLICATION NOBYPASSRLS;

CREATE SCHEMA agent_demo;
REVOKE ALL ON SCHEMA agent_demo FROM PUBLIC;

CREATE TABLE agent_demo.orders (
  id integer PRIMARY KEY,
  store_id integer NOT NULL,
  net_amount numeric(12,2) NOT NULL
);

CREATE TABLE agent_demo.customer_private (
  customer_id integer PRIMARY KEY,
  email text NOT NULL,
  internal_note text NOT NULL
);

INSERT INTO agent_demo.orders VALUES
  (1, 10, 120.00),
  (2, 10, 80.00),
  (3, 20, 350.00);

INSERT INTO agent_demo.customer_private VALUES
  (1, 'example@example.invalid', 'Fictional private note');

REVOKE ALL ON ALL TABLES IN SCHEMA agent_demo FROM PUBLIC;
GRANT CONNECT ON DATABASE ai_scope_demo TO ai_order_reader;
GRANT USAGE ON SCHEMA agent_demo TO ai_order_reader;
GRANT SELECT ON agent_demo.orders TO ai_order_reader;

The schema’s USAGE grant lets the role resolve object names. It does not grant permission to read every table in that schema. There is deliberately no SELECT grant on customer_private and no write grant on either table.

This is a narrow demonstration, not a complete production access audit. Existing databases may grant access through PUBLIC, other role memberships, views, or executable functions. Removing a direct grant does not cancel those other paths. Review the role’s effective privileges, ownership, and ability to assume other roles, not just the last GRANT statement. Postgres documents these rules in its privileges guide.

Connect with the restricted identity

In Bufflehead, create a direct Postgres connection to ai_scope_demo using ai_order_reader and your configured authentication. Use the appropriate TLS settings for your environment. Do not use the administrator’s connection for the checks below.

First verify the identity in Bufflehead’s SQL editor:

SELECT current_user, session_user, current_database();

Both user fields should identify ai_order_reader, and the database should be ai_scope_demo. A successful test as an administrator says nothing about the restricted role.

The allowed query succeeds

SELECT store_id,
       COUNT(*) AS order_count,
       SUM(net_amount) AS order_total
FROM agent_demo.orders
GROUP BY store_id
ORDER BY store_id;

For the fixture, store 10 has two orders totaling 200.00; store 20 has one totaling 350.00. The assistant can answer an order-total question without reading customer contact information.

The restricted query fails

SELECT email, internal_note
FROM agent_demo.customer_private;

Postgres should return permission denied for table customer_private. This is the useful negative test: naming the table directly does not grant access. If the query succeeds, stop and inspect the connection identity and effective grants before using real sensitive data.

You can also inspect the table privileges without attempting a write:

SELECT
  has_table_privilege(current_user,
    'agent_demo.orders', 'SELECT') AS can_read_orders,
  has_table_privilege(current_user,
    'agent_demo.customer_private', 'SELECT') AS can_read_private,
  has_table_privilege(current_user,
    'agent_demo.orders', 'UPDATE') AS can_update_orders;

For this fresh fixture, the expected values are true, false, and false. These checks illustrate table privileges; they are not a comprehensive audit of every possible access path.

Table scoping is not customer isolation

Notice what the allowed query returned: both stores. The role can read every row in orders. That is appropriate for this example’s internal reporting task, but not for a customer-facing assistant that must see only its own store.

Adding WHERE store_id = 10 to a generated query does not create an access boundary if the caller can omit or change it. Customer isolation requires an enforced design—for example, carefully scoped views or Postgres row-level security, with a trusted way to bind the connection to the customer. A shared database login does not automatically tell Postgres which end customer is asking.

If you use Postgres row-level security, test it with the actual application role. Superusers and roles with BYPASSRLS bypass it, and table owners normally do too unless forced to follow it. Test allowed rows and forbidden rows for more than one customer. This walkthrough does not configure RLS, and it does not claim Bufflehead supplies automatic per-customer identity mapping.

Where Bufflehead fits

Bufflehead is the read-only bridge between your AI workflow and an existing database. It does not need to create the demo tables, grant permissions, or manage a customer-access policy. Those are administrator responsibilities, completed before connecting.

Use a credential scoped to the task as well as a read-only workflow. Keep those credentials consistent between manual testing and AI use. Schema discovery can help the model find useful data, but a restricted table need not have a secret name to remain unreadable.

Finally, a permitted result is still data disclosed to whoever receives it. If you pass query results to a hosted AI service, that is a separate data-sharing decision; a local database connection does not change the destination of those results.

The practical standard is simple: prove that a useful query works, then prove that an out-of-scope query fails with the same identity. Read-only access and a read-access policy complement each other. Neither is a substitute for the other.

Download Bufflehead to query an existing database, or continue with a focused SQL workflow instead of pasting entire tables into a prompt.

Try it on your own data.

Native binary, no Docker, no cloud egress. Free for local files and direct database connections — request a demo for the AWS SSM path.

Download free Request a demo