DEV Community

Cover image for Your LangChain SQL agent sends the model every table, including the ones that caller can't read
Ashish sinha
Ashish sinha

Posted on

Your LangChain SQL agent sends the model every table, including the ones that caller can't read

A LangChain SQL agent hands the model your schema and asks it to write SQL. On a demo database with eight tables that is fine. On a real one it breaks in two ways at once.

The schema outgrows the context window. I wrote about this before, on a warehouse with 1,245 tables — the table listing alone did not fit, and describing every table with an LLM to help retrieval made it worse, not better.

And the model sees tables the caller is not allowed to read. This one is quieter and worse. SQLDatabase.get_table_info() returns what the connection can see, not what the person asking can see. If your app connects as one service account and serves twenty users, every user's agent gets the full schema: salaries, PII, all of it. The model may never write SQL against those tables. It was still told they exist, and their column names went into the prompt.

So I wrote a retriever.

SchemagateRetriever

from schemagate import Catalog, Principal
from schemagate.integrations.langchain import SchemagateRetriever

cat = Catalog().bootstrap("postgresql://localhost/app")

retriever = SchemagateRetriever(
    catalog=cat,
    top_k=6,
    principal=Principal("okta:jdoe", roles={"finance"}),
)

docs = retriever.invoke("revenue by month")
Enter fullscreen mode Exit fullscreen mode

It subclasses BaseRetriever, so it drops into any chain that already takes a retriever. Each selected object comes back as one Document: the DDL in page_content, and the name, kind, score and selection reason in metadata.

Two things happen before the model sees anything. The caller's grants decide which objects are candidates at all. Then the question decides which of those are relevant. A table jdoe cannot read is not ranked and then filtered out — it never enters the ranking.

The principal is bound at construction, not passed per call

This is the part I would push back on in someone else's library, so here is the reasoning.

SchemagateRetriever(catalog=cat, principal=p)   # identity fixed here
retriever.invoke(question)                      # not here
Enter fullscreen mode Exit fullscreen mode

A retriever is usually built once per request. Binding identity to the object means a chain cannot forget to pass it. There is no call signature where the principal is optional and quietly defaults to everything. If you want a different caller, build another retriever — they are cheap.

The alternative, invoke(question, principal=...), has one failure mode I did not want to ship: somebody omits the argument, the call still succeeds, and it returns the whole schema. Fail-closed beats convenient.

Install

pip install 'schemagate[langchain]'
Enter fullscreen mode Exit fullscreen mode

Needs langchain-core>=0.3, and it is an optional extra — if you do not use LangChain you do not pay for it.

Postgres, Oracle, MySQL and SQL Server. On Oracle it reads VPD policies rather than inferring from grants.

If you are running a SQL agent against a database where different callers should see different tables, I would like to know how you handle it today — especially if the answer is "one service account and we hope". That was the answer where I work, which is why this exists.

Top comments (0)