Perspective

How Do You Safely Connect an AI to a Production Database? Why a Read-Only User Isn't Enough

Ask "why did revenue drop last month?" and have the AI go look through your company's data for the answer. That's genuinely useful. But if you rush the implementation and hand the model your production connection string plus a run_sql-style tool, you have built a system that executes model-generated strings against production. That is a frightening architecture.

Model output is not a trusted instruction. It isn't only what the user typed — instructions aimed at the AI can also arrive inside support tickets or notes already stored in your database. OWASP catalogs this as indirect prompt injection.

This article builds a setup, using PostgreSQL, where the AI reads only the data it needs. Beyond blocking writes, it covers keeping other tenants' rows and personal data out of reach, and keeping heavy queries off the production database.

Assumed versions: The SQL and Python here target PostgreSQL 18, Python 3.12, and Psycopg 3.3.x. PUBLIC privileges and RLS (row-level security) setups differ between existing databases, so verify effective privileges in staging before applying any of this to production.

Give the AI purpose-built tools, not raw SQL

I would not hand a model run_sql(sql: string). Instead, I'd define tools in the application whose purpose is already decided:

  • get_daily_sales(date_from, date_to)
  • compare_plan_usage(plan, period)
  • find_failed_imports(source_id, limit)

What the AI decides is which function to call, with which arguments — and nothing more. Assembling the SQL, attaching the tenant ID, authorization, and execution limits all stay in ordinary application code.

Between the AI and the database sits a Query Broker:

Authenticated user
       ↓
AI orchestrator
       ↓
Query Broker  ──→  Audit log
       ↓
Reporting view
       ↓
Read replica

The Query Broker is a small backend that inherits the authenticated user's permissions and runs only the queries you allowed. Where possible, point it at a read replica or an analytics database rather than the primary.

A Query Broker on its own still isn't enough. Layer the limits so that a bug in the application is stopped at the database.

BoundaryWhat it preventsMain controls
AI toolsUnintended operationsPurpose-built functions, structured arguments
ApplicationImpersonation, over-fetchingAuthorization, argument validation, row caps, masking
DatabaseWrites, out-of-scope readsDedicated role, views, RLS, read-only transactions
InfrastructureProduction load, network exposureRead replica, private connectivity, connection limits
OperationsMissed anomaliesAudit logs, alerts, revocation procedures

A "read-only user" does not go far enough

It's tempting to think "read-only means nothing can be broken." Write accidents do become rarer. But as long as SELECT is permitted, quite a lot remains possible:

  1. Reading other tenants' rows that live in the same table
  2. Reading columns irrelevant to analysis, like email addresses and postal addresses
  3. Burning CPU and I/O on the primary with huge joins or aggregations
  4. Shipping enormous result sets to the model, driving up cost and exposure
  5. Holding function or schema privileges inherited through PUBLIC

And in PostgreSQL, effective privileges are not only the ones granted directly to a role. Privileges of granted roles and of PUBLIC are added on top. Functions and procedures get EXECUTE for PUBLIC by default, so an existing database deserves a one-time audit. The PostgreSQL privileges documentation covers the details.

"Cannot write" and "can only read what it's allowed to see" are two different properties.

Build a read-only window inside the database

As an example, the AI gets to see daily sales and nothing else. The underlying orders table also holds email addresses, shipping addresses, and free-text notes — none of which are needed to explain a revenue trend. So we create a view in a separate schema, aggregated down to date, order count, and revenue. app_owner is an existing NOLOGIN role that can read orders.

-- db/ai_reader.sql
BEGIN;

CREATE SCHEMA ai_read AUTHORIZATION app_owner;

CREATE VIEW ai_read.daily_sales
WITH (security_barrier = true)
AS
SELECT
    tenant_id,
    ordered_at::date AS sales_date,
    count(*) AS order_count,
    sum(total_amount) AS revenue
FROM app.orders
WHERE status = 'paid'
GROUP BY tenant_id, ordered_at::date;

ALTER VIEW ai_read.daily_sales OWNER TO app_owner;

CREATE ROLE ai_reader
    LOGIN
    NOSUPERUSER
    NOCREATEDB
    NOCREATEROLE
    NOINHERIT
    NOREPLICATION
    NOBYPASSRLS
    CONNECTION LIMIT 5;

-- If you use password authentication, set the password separately from a
-- secret manager.

GRANT CONNECT ON DATABASE app TO ai_reader;
GRANT USAGE ON SCHEMA ai_read TO ai_reader;
GRANT SELECT ON ai_read.daily_sales TO ai_reader;

ALTER ROLE ai_reader IN DATABASE app
    SET default_transaction_read_only = on;
ALTER ROLE ai_reader IN DATABASE app
    SET statement_timeout = '3s';
ALTER ROLE ai_reader IN DATABASE app
    SET lock_timeout = '500ms';
ALTER ROLE ai_reader IN DATABASE app
    SET idle_in_transaction_session_timeout = '5s';
ALTER ROLE ai_reader IN DATABASE app
    SET search_path = ai_read, pg_catalog;

COMMIT;

ai_reader cannot read the original orders. The only thing it can reference is ai_read.daily_sales. Making the raw data unfetchable in the first place is safer than fetching it in the application and then dropping columns.

security_barrier prevents a malicious function or operator from being evaluated ahead of the view's own filters. If a view is going to act as a security boundary, this setting isn't optional. PostgreSQL's Rules and Privileges explains the behavior in detail.

Watch out for over-broad grants: GRANT pg_read_all_data TO ai_reader is easy, but far too wide for an AI role. That predefined role grants read access to every table, view, and sequence, plus USAGE on every schema. Grant SELECT on each view the AI actually needs instead.

Caveats when using RLS

In a multi-tenant database, RLS narrows the rows a role can see. When a table has RLS enabled and no policy applies, PostgreSQL denies the access.

The pitfall is that superusers, roles with BYPASSRLS, and ordinary table owners can bypass RLS. To apply it to the table owner too, you need FORCE ROW LEVEL SECURITY. PostgreSQL's Row Security Policies spells out these exceptions.

Combining views with RLS is fiddly as well. Which RLS policies apply depends on whose privileges the view runs under. Adding security_invoker evaluates it as the caller, but that caller then also needs privileges on the base tables. It isn't a matter of flipping one option and being done.

In production, pick one of the following and test that rows from another tenant never appear:

  • Purpose-built queries that always include the authorized tenant_id in the predicate
  • RLS plus a dedicated view-owning role that does not bypass it
  • An analytics data store isolated per tenant or per customer

Implementing the Query Broker in Python

The Query Broker's job is unglamorous: validate the arguments it received from the AI, then run a query you decided on in advance. Here we use Psycopg 3.3.4 and Pydantic 2.13.4.

python -m venv .venv
source .venv/bin/activate
pip install "psycopg[binary,pool]==3.3.4" "pydantic==2.13.4"

The date range is capped at 31 days. tenant_id is never chosen by the AI — it comes from the authenticated user's context.

# src/query_broker.py
import os
from datetime import date
from decimal import Decimal
from typing import Any
from uuid import UUID

from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from pydantic import BaseModel, model_validator


class DailySalesArgs(BaseModel):
    date_from: date
    date_to: date

    @model_validator(mode="after")
    def validate_period(self) -> "DailySalesArgs":
        days = (self.date_to - self.date_from).days
        if days < 0:
            raise ValueError("date_to must be on or after date_from")
        if days > 30:
            raise ValueError("the maximum period is 31 days")
        return self


pool = ConnectionPool(
    conninfo=os.environ["AI_DATABASE_URL"],
    min_size=1,
    max_size=5,
    open=True,
    kwargs={"row_factory": dict_row},
)


QUERY = """
    SELECT sales_date, order_count, revenue
    FROM ai_read.daily_sales
    WHERE tenant_id = %s
      AND sales_date BETWEEN %s AND %s
    ORDER BY sales_date
"""


def get_daily_sales(
    authenticated_tenant_id: UUID,
    raw_args: dict[str, Any],
) -> list[dict[str, date | int | Decimal]]:
    args = DailySalesArgs.model_validate(raw_args)

    with pool.connection() as conn:
        with conn.transaction():
            # On top of the role defaults, enforce read-only per execution.
            conn.execute("SET TRANSACTION READ ONLY")
            conn.execute("SET LOCAL statement_timeout = '3s'")
            conn.execute("SET LOCAL lock_timeout = '500ms'")

            rows = conn.execute(
                QUERY,
                (
                    authenticated_tenant_id,
                    args.date_from,
                    args.date_to,
                ),
            ).fetchall()

    return rows

The most important thing about this code is that tenant_id is not part of raw_args. The server passes the tenant ID it established by validating a JWT or similar into authenticated_tenant_id. Even if the model emits another company's ID, it never reaches the query.

Dates are validated by Pydantic, and passed to SQL as Psycopg parameters — there is no string concatenation anywhere.

In a PostgreSQL read-only transaction, INSERT, UPDATE, and DELETE against non-temporary tables are forbidden, along with most DDL. The SET TRANSACTION documentation lists the affected commands.

Of course, an expensive SELECT still runs in read-only mode. That's why we set statement_timeout, and put caps on both the pool's max_size and the date range. Psycopg's ConnectionPool also bounds how many connections the application opens at once.

Reduce the data before it reaches the model

Data you were able to fetch from the database is not automatically data you should send to the model. The database connection can be perfectly safe and you can still create a problem elsewhere by shipping unnecessary personal data to a model provider.

Shrink the data before it gets to the model:

  1. Don't expose unnecessary columns in the view
  2. Filter rows in SQL and aggregate inside the database
  3. Check row count and payload size in the application, and mask where needed
  4. Pass only what's left to the model

To explain a revenue trend, you don't need customer names, email addresses, or order notes. Date, order count, and revenue are enough.

Vetting a model provider is about more than whether your data trains their models. Check retention period and storage location, deletion process, logging, region, and contractual terms. OWASP's Sensitive Information Disclosure also lists data sanitization, access control, and limiting reachable data sources among its mitigations.

When you really do need generated SQL

In BI, you can't always enumerate every kind of question in advance. That's where text-to-SQL comes in. It demos beautifully — but avoid executing generated SQL as-is in production.

The daily_sales view above holds aggregated rows for every tenant. It's safe because fixed SQL always attaches the authenticated tenant_id. If you're going to allow generated SQL, put an RLS-backed view or a per-tenant analytics store in front of it first. Treating "the model generated a WHERE tenant_id = ..." as your authorization is dangerous.

Generated SQL needs at least the following controls.

1. Inspect the SQL as a syntax tree

Regular expressions can't correctly handle comments, CTEs, subqueries, quoting, or dialect differences. Parse the statement into a syntax tree with a PostgreSQL-aware SQL parser, and check that:

  • There is exactly one statement
  • The top level is a SELECT, or a WITH ... SELECT you allow
  • Only allowed schemas and views are referenced
  • No INSERT, UPDATE, DELETE, MERGE, COPY, or DDL appears
  • No disallowed function, system catalog, or outbound-connection feature is referenced
  • A result row cap is in place

Inspecting the AST does not let you skip database privileges. Parsers have bugs and unsupported syntax, so run even the SQL that passed inspection as ai_reader, inside a read-only transaction.

2. Judge the cost before executing

EXPLAIN (FORMAT JSON) gives you the estimated cost, estimated row count, and the tables involved. Reject queries that exceed your thresholds right there. Estimates go wrong when statistics are stale, so this is not an absolute defense. Note that EXPLAIN ANALYZE actually runs the query, which makes it unsuitable for pre-flight screening.

If you want query load definitively off the primary, connect to a read replica or an analytics platform. Replicas aren't infinitely durable either, so monitor CPU, I/O, replication lag, and concurrency.

3. Keep a human in the loop at first

Require human approval for new data sources and views, for queries judged expensive, and for results containing personal data. Run it that way for a while, and automate the patterns you've confirmed to be safe.

OWASP's Excessive Agency likewise recommends avoiding open-ended general-purpose tools, minimizing permissions in downstream systems, and requiring human approval for high-impact actions.

Keep connection details away from the model

Never put a database connection string in the model's context or in a tool argument, and keep it out of conversation history. Only the Query Broker connects, and it fetches credentials at runtime from a secret manager or equivalent.

If your database supports IAM or managed identity, short-lived credentials are easier to live with than a long-lived password. Tokens generated by Amazon RDS IAM database authentication, for instance, are valid for 15 minutes — and the application never has to store a database password.

Short-lived tokens only solve authentication. Which tables and rows are readable is still decided by database roles, views, and RLS.

The network deserves the same treatment. Don't expose the database to the internet and rely on IP allowlists alone — use the same VPC, Private Link, the Cloud SQL Connector, or similar. Don't disable TLS verification, and restrict the source to the Query Broker.

Log who, why, and what

Database SQL logs alone won't tell you whose question produced a given query. Record the following in the Query Broker:

  • Authenticated user ID and tenant ID
  • Conversation ID, request ID, tool name
  • The template ID or normalized SQL of the executed query
  • Hashed or masked argument values
  • The views referenced
  • Execution time, rows returned, bytes returned
  • The outcome: allowed, denied, timed out, or errored

What you do not record: connection strings, access tokens, the personal data you fetched, or the full text sent to the model. Log everything and the audit log becomes your next leak path.

On the database side, pg_stat_statements gives you planning and execution statistics for your SQL. Correlate it with the application's request ID and expensive queries become much easier to trace back.

Test before you go to production

Confirming the happy path isn't enough. Test on the assumption that the model is doing exactly what an attacker told it to do:

  • UPDATE, DELETE, and DROP TABLE are rejected by database privileges
  • Disallowed tables and information_schema cannot be referenced
  • Supplying another tenant's ID returns no data
  • Ranges over 31 days and malformed dates are rejected by the Query Broker
  • Queries with pg_sleep() or a huge cross join are refused, or time out
  • Concurrent requests never exceed the connection limit
  • Transactions and connections are reclaimed cleanly after a timeout
  • Database error messages and schema details are not returned verbatim to users
  • The audit log lets you trace the caller, tool, target view, and row count
  • Revoking credentials makes new connections fail immediately

Writing "never reveal secrets" in the system prompt will not make these tests pass. OWASP itself notes there is no clear way to fully prevent prompt injection. This is a part you stop with privileges, ordinary code, human approval, and adversarial tests — not with prompt wording.

Start with fixed queries

I don't think it's wise to aim for open-ended text-to-SQL from day one. Start with fixed queries and add freedom only as you need it:

  1. Verify answer quality against anonymized development data
  2. Connect only purpose-built fixed queries to an analytics database
  3. Add reporting views and widen the range of structured arguments
  4. Try generated SQL against allowed views on a read replica
  5. Automate a subset of queries based on your audit and approval track record

If fixed queries are enough, there's no obligation to move on to generated SQL. If you can turn a user's question into a safe operation and explain the result clearly, the AI is already earning its place.

What this looks like in Phaide AI

Building all of the above yourself is real work. Design the reporting views and the dedicated role, write the Query Broker, operate the ingestion path, get the audit logging right — that all stacks up before anyone runs a single analysis.

Phaide AI provides that layer, managed:

  • Connections go through managed connectors. Alongside PostgreSQL and MySQL, there are over 700 supported data sources including Snowflake, BigQuery, Salesforce, and HubSpot. The AI never queries your production primary directly — it reads the imported analysis database.
  • Personal data is dropped at import. Mark columns like names, email addresses, and customer IDs as masked, and their values are replaced with deterministic tokens before they are written to the analysis database. Type a real name into chat and it is converted to the same token before the message is sent.
  • The AI and its sandbox only ever see tokens. Because the conversion is deterministic, counts, joins, and groupings still work on masked columns. Values are restored only as they render on your screen.
  • One setup, everywhere. A column you protect once stays protected across chat, dashboards, and the autonomous exploration agent. There is no surface that reads the raw data.

Think of it as a product implementation of this article's core idea: establish "it can only read what it's allowed to see" upstream of the AI. The masking mechanics are covered in detail in the data masking article, and you can see the whole platform at Phaide AI.

Summary

  • Don't hand the AI open-ended SQL execution; start with purpose-built read tools.
  • Set up a dedicated role, reporting views, and timeouts on the database side too.
  • Don't let the model pick the tenant ID — derive it from the authentication result.
  • Narrow the data at fetch time and send only the necessary results to the model.
  • If you use generated SQL, design it around the question: would the damage stay contained if the model did exactly what an attacker asked?

References

Related
GuideHow to Use Phaide via MCP8 min readAnnouncementPhaide AI is live: AI agents that explore your data and find problems for you4 min readPerspectiveWhat is Agentic BI? The Next Evolution of Business Intelligence11 min read

Give it a shot — this is something different.

Connect the MCP endpoint and ask your agent a real question about your data.

Try Free