DEV Community

Vivek Kumar
Vivek Kumar

Posted on

Let Your SaaS Customers Ask Their Data Questions — Without Handing Over the Keys

Every SaaS product eventually collects a data request it didn't plan for. A customer emails: "Can you tell me how many active seats we used each month last quarter?" Your dashboard shows totals, not that exact cut. So an engineer writes a one-off query, pastes the result into a reply, and moves on — until the next customer asks something slightly different.

Dashboards can only anticipate so many questions. The dream is to let customers ask in plain English — "what was our churn last month?", "which of my projects has the most open tickets?" — and get an answer straight from the data you already store on their behalf. The scary part is obvious: you'd be pointing an AI at a shared, multi-tenant production database where one wrong WHERE clause leaks Customer A's numbers to Customer B.

The good news is that this is a solved problem, and it doesn't require trusting the AI to get filtering right. It requires two layers that have nothing to do with the AI at all: row-level security in the database, and a broker (typically an MCP server) that scopes every connection to one tenant. Let the database enforce isolation and let the broker hold the credentials. The AI just writes SQL against a door that only opens onto one customer's data.

The setup: one database, many customers

Most B2B SaaS apps are multi-tenant — every customer's rows live in the same tables, tagged with a tenant_id (or account_id, org_id, whatever you call it).

CREATE TABLE subscriptions (
  id           bigint PRIMARY KEY,
  tenant_id    uuid NOT NULL,
  plan         text NOT NULL,
  seats        int  NOT NULL,
  status       text NOT NULL,       -- 'active', 'canceled', 'trialing'
  created_at   timestamptz NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The whole game is making sure that no query — whether written by your app, an engineer, or an AI — can ever return rows where tenant_id doesn't match the customer who's asking. Doing that in application code means remembering to add AND tenant_id = $1 to every single query, forever. Miss it once and you have a data breach. So push the rule down into the database instead.

Layer 1: row-level security does the isolation

PostgreSQL's row-level security (RLS) lets you attach a policy to a table so the database itself filters rows based on a runtime value — no matter what query arrives.

-- Turn on RLS for the table
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

-- Only return rows for the tenant in the current session context
CREATE POLICY tenant_isolation ON subscriptions
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
Enter fullscreen mode Exit fullscreen mode

Now the tenant identity travels with the connection, not the query. You set it once per request:

-- Scope the value to THIS transaction only (critical for pooled connections)
SET LOCAL app.current_tenant = '8f3a...c2';

SELECT count(*) FROM subscriptions WHERE status = 'active';
-- Returns only tenant 8f3a...c2's active subscriptions, always.
Enter fullscreen mode Exit fullscreen mode

Notice the query has no tenant_id filter in it at all. The database added it. Even a careless SELECT * FROM subscriptions returns only the current tenant's rows. As one Crunchy Data write-up puts it, there's "zero chance of forgetting a tenant filter" because Postgres won't allow a cross-tenant query in the first place.

Two details that turn a demo into something production-safe:

SET LOCAL, not SET. Connection poolers reuse physical connections across requests. SET LOCAL binds the value to the current transaction so it can't leak into the next customer's request on the same connection. SET persists for the whole session and will eventually hand one tenant another tenant's context.

Index the tenant column first. RLS effectively prepends tenant_id = ? to every scan. Without tenant_id as the leading column of your indexes, those scans get dramatically slower — often two orders of magnitude on large tables.

CREATE INDEX idx_subs_tenant_status
  ON subscriptions (tenant_id, status);
Enter fullscreen mode Exit fullscreen mode

Layer 2: a broker gives customers a door — not your credentials

RLS handles isolation. But how does a customer's AI assistant actually reach the database? You are not going to email them a connection string. This is where a broker sits in the middle — increasingly, one that speaks the Model Context Protocol (MCP), an open standard for connecting AI assistants to tools and data.

The pattern looks like this. Each customer authenticates to the broker over OAuth and gets a token that encodes their tenant_id. When their AI client wants to run a query, it sends the request to the broker, which:

  1. Validates the customer's OAuth token and extracts the tenant_id from it.
  2. Opens a database connection using your credentials (the AI never sees them).
  3. Runs SET LOCAL app.current_tenant = <tenant_id from the token>.
  4. Executes the AI's SQL — now automatically filtered by RLS — and returns just the rows.

The properties that fall out of this are exactly what you want for a customer-facing feature:

Concern How the two layers handle it
Credential exposure The broker holds the DB connection. The AI model and client never see raw credentials, tokens, or secrets.
Cross-tenant leaks RLS filters every query by tenant_id at the database level, even if the AI writes a bad query.
Accidental writes Give the broker a read-only role — SELECT only, so an AI can explore but never modify data.
Revoking access OAuth tokens are centrally revocable; no long-lived secret lives in a prompt, chat log, or config file.
Auditability The broker logs the natural-language question, the generated SQL, and the result for every request.

You can build this broker yourself, or use a managed MCP server. Draxlr, for example, offers one that connects over OAuth and is read-only by design — one implementation of the pattern above — but the architecture matters more than any product: RLS in the database, a credential-holding broker in front of it.

Putting it together: a request's journey

Say a customer opens their AI assistant and types:

"How many active seats did we have at the end of last month?"

The AI, given only your schema (not your data), writes:

SELECT sum(seats) AS active_seats
FROM subscriptions
WHERE status = 'active';
Enter fullscreen mode Exit fullscreen mode

The broker sets app.current_tenant from the customer's token and runs it. RLS scopes the sum to that tenant. The result comes back:

active_seats
142

The customer sees 142 — their number, and only their number. They never learned SQL, never got a database login, and couldn't have reached another tenant's row if they'd tried. The same door works whether they ask from Claude, Cursor, ChatGPT, or an in-app chat box, because the isolation lives in the database, not the client.

Common mistakes and gotchas

Table owners bypass RLS by default. In Postgres, the role that owns a table isn't subject to its own RLS policies unless you force it. If your broker connects as the table owner, isolation silently does nothing. Add:

ALTER TABLE subscriptions FORCE ROW LEVEL SECURITY;
Enter fullscreen mode Exit fullscreen mode

Superuser and BYPASSRLS roles ignore policies entirely. Never let the broker connect as a superuser or a role with BYPASSRLS. Give it a dedicated, low-privilege, read-only role.

Forgetting policies on new tables. RLS is per-table and off by default. Add a new table, forget the policy, and it's wide open to every tenant. Make "enable RLS + add tenant policy" part of your migration checklist, or write a test that fails when a tenant-scoped table lacks a policy.

SET instead of SET LOCAL on pooled connections. Worth repeating because it's the subtlest leak: a persistent SET outlives the request and the next tenant inherits it. Always scope to the transaction.

Trusting the AI to add the filter. The AI should never be your isolation boundary. If your only protection is "the prompt tells it to filter by tenant," you have no protection. RLS is what makes the AI's mistakes harmless.

No composite index on tenant_id. Correct but slow is still a bug when a customer is waiting on an answer. Lead your indexes with the tenant column.

Key takeaways

Letting customers ask their data questions in plain English is no longer exotic — but the safety comes from architecture, not from trusting the model. Enforce isolation in the database with row-level security so no query, human or AI, can cross tenants. Put a broker in front that holds your credentials, scopes each connection to one tenant via OAuth, and stays read-only. Then the AI is just a convenient way to write SELECT statements against a door that only ever opens onto one customer's data. Index your tenant column, force RLS on owned tables, keep the broker's role low-privilege, and audit every generated query.

Do that, and "can you pull this number for us?" stops being a support ticket and starts being something your customers answer themselves — in seconds, safely.

Your turn

Are you exposing data back to your customers yet — through dashboards, an API, or natural language? If you've built multi-tenant RLS in production, I'd love to hear what bit you: the pooling gotcha, the owner-bypass surprise, or something else entirely. Drop it in the comments.

Top comments (0)