case studies

What an AI Support Agent Could Read Before Its Database Login Went Live

This case study runs on the sample multi-tenant database that BareSQL’s tests build. The software company in it is made up: it runs one PostgreSQL database for all its customers, the tenants, and row security keeps each tenant’s rows apart. Every output below is what BareSQL printed.

The support team wants an AI agent to answer tickets, so the agent gets a login of its own, saas_agent, in the saas_support group that support’s views are granted to. The views were written for people, months ago, and nobody remembers exactly what they return. So before the login goes live, the team audits that one login.

The findings of the BareSQL Audit report for the support agent’s login before go-live: a high finding for the view support.ticket_context, which skips row security, then the email addresses and names the agent can read.

The command and what it prints:

baresql audit --login saas_agent "postgres://postgres@localhost/baresql_fx_saas"
BareSQL Audit of baresql_fx_saas (PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)) as postgres
1 login, which can read personal data; 5 columns hold personal data.
Findings: 1 high, 1 medium, 1 low
  high   view-bypasses-row-security   support.ticket_context
  medium rls-owner-login              app.audit_log
  low    rls-no-policies              app.api_keys
Wrote baresql-report/report.html and baresql-report/audit.json

The View That Hands Out Every Tenant

The high finding is about support.ticket_context, the view that shows a ticket with the customer’s name and email address. These are its routes to the customers’ data, as --format lines prints them:

read saas_agent app.accounts.email [email] through view support.ticket_context (rights of saas_owner); all rows (owner; row security not forced)
read saas_agent app.accounts.full_name [name] through view support.ticket_context (rights of saas_owner); all rows (owner; row security not forced)

A view reads its tables with its owner’s rights unless it says otherwise, and this one’s owner, saas_owner, owns app.accounts. Row security doesn’t apply to a table’s owner unless the table forces it, and this one doesn’t. So the policies that keep tenants apart never run for this view, and the agent, asked about one ticket, can read the name and email address of every customer with a ticket, in every tenant. A prompt that talks the agent into listing them would be enough.

The Safer View That Fails

A second view, support.ticket_context_invoker, was written later with security_invoker, so it reads with the agent’s own rights. It’s granted to support as well. The audit lists it and says it gives nothing:

read saas_agent support.ticket_context_invoker.email [email] granted to saas_support; reading it fails: the view needs app.accounts.email, app.accounts.full_name and app.accounts.id, which the reader may not read

The agent may not read app.accounts itself, and PostgreSQL checks every column a view’s query reads, so it refuses every query on this view, even one that selects only the ticket columns. The agent’s developers would have met that error on the first test and gone back to the view that works.

What the Audit Leaves to You

The agent reads tickets with “rows allowed by policy support_read”. BareSQL names the policy and doesn’t judge its condition, so that part is left to a person. Here the condition was USING (true): support could read every tenant’s tickets, too.

The Fix

Both views should read with the reader’s rights, and support should see one tenant at a time, the one its session works for. The team made four changes:

ALTER VIEW support.ticket_context SET (security_invoker = true);
GRANT SELECT (id, email, full_name) ON app.accounts TO saas_support;
CREATE POLICY support_read ON app.accounts FOR SELECT TO saas_support
  USING (tenant_id = current_setting('app.tenant_id')::integer);
ALTER POLICY support_read ON app.tickets USING (tenant_id = current_setting('app.tenant_id')::integer);

And audited again:

BareSQL Audit of baresql_fx_saas (PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)) as postgres
1 login, which can read personal data; 5 columns hold personal data.
Findings: 0 high, 1 medium, 1 low
  medium rls-owner-login              app.audit_log
  low    rls-no-policies              app.api_keys
Wrote baresql-report/report.html and baresql-report/audit.json

The high finding is gone. The two others are about tables the agent doesn’t touch: an audit log whose owner can log in and so skips row security, and a table of API keys with row security on and no policy, which only its owner and roles that bypass row security can read. Every route of the agent to a customer’s data now ends in “rows allowed by policy support_read”, and the policy now limits rows to the session’s tenant.

Why the Diff Says Reach Grew

baresql diff on the two audits reports 5 new pieces of reach:

Compared the audit of 2026-10-20T09:00:00Z with the audit of 2026-10-21T09:00:00Z.
Reach that grew: 5
  + read saas_agent app.accounts.id (column granted to saas_support; rows allowed by policy support_read)
  + read saas_agent support.ticket_context_invoker.email (email address; granted to saas_support)
  + read saas_agent support.ticket_context_invoker.full_name (person's name; granted to saas_support)
  + read saas_agent support.ticket_context_invoker.id (granted to saas_support)
  + read saas_agent support.ticket_context_invoker.subject (granted to saas_support)
Reach that is gone: 0

For a fix, that looks odd at first. The direct column grant gives support app.accounts.id, which it didn’t read before, and the view that failed now works. All of it comes with the new tenant policy. The diff counts any route that starts giving rows as growth, since that is exactly what a reviewer has to look at, and here the reviewer can see each one and why.

The team keeps the second audit.json. From now on a nightly baresql audit --baseline fails as soon as the agent’s login reaches anything new, before anyone has asked the agent about it. Both reports are on the sample reports page.