DEV Community

Vivek Kumar
Vivek Kumar

Posted on

Who Just Queried Prod? Auditing and Controlling AI Database Access Across a Team

Six months ago, exactly one person on your team could query the production database from an AI tool. Today it's everyone. Someone pasted a connection string into an AI assistant, it worked beautifully, and the pattern spread. Now three engineers, a product manager, and a support lead all ask a chatbot questions that quietly turn into SELECT statements against live data.

That's genuinely useful. It's also a governance blind spot. If someone asks "why did this customer's numbers look weird last Tuesday," can you answer who — or what — ran the query that touched their records? With scattered connection strings and shared credentials, the honest answer is usually "no idea."

This article is about closing that gap: how to give a team AI-powered database access while keeping it identifiable, scoped, and auditable. The goal isn't to slow anyone down. It's to make sure that when access grows from one person to twenty, you still know what's happening.

The failure mode: shared secrets and no paper trail

The default way people connect an AI tool to a database is to hand it a connection string:

postgresql://app_user:s3cr3t@db.internal:5432/production
Enter fullscreen mode Exit fullscreen mode

Do that across a team and you inherit four problems at once. Everyone shares one database identity, so every query looks identical in the logs. The secret lives in prompts, chat histories, config files, and screenshots. Rotating it means chasing down every place it was pasted. And the database has no idea whether a human or a model issued a given statement.

The database's own audit log doesn't save you here, because from its point of view there's a single user, app_user, doing everything. You've lost the two facts that matter most for governance: which person the access belongs to, and whether their tool was allowed to do what it did.

What good looks like: a broker between the AI and the database

The pattern that fixes this is putting a broker — often an MCP (Model Context Protocol) server — between AI clients and the database. Instead of each tool holding raw credentials, they connect to one governed gateway that holds the connection and enforces the rules.

Model Context Protocol is an open standard for connecting AI assistants to external systems through a consistent interface. For databases, an MCP server exposes a small set of operations (list tables, fetch schema, run a read-only query) and becomes the single place where identity, permissions, and logging live.

Routing everyone through one gateway gives you five properties that scattered connection strings can't:

Property What it means in practice
Per-person identity Each teammate authenticates as themselves (usually via OAuth), so every query is attributable to a human.
Least privilege The broker can be read-only by design — SELECT passes, UPDATE/DROP get rejected before they reach the DB.
Central revocation Off-boarding is one toggle. No long-lived secret to hunt down across chat logs.
Audit trail One chokepoint logs who ran what, against which database, and when.
No shadow access The database isn't network-exposed to every laptop; one gateway is.

Managed MCP servers like Draxlr's implement this shape — connected over OAuth, read-only, with the credentials held server-side — but the pattern matters more than any product. You can build the same thing in-house. What follows works either way.

Building the audit trail

Whatever broker you use, the non-negotiable is a log of every query with enough context to answer "who did this and why." At minimum, capture the acting identity, the SQL, the target database, a timestamp, and a correlation ID that ties a query back to the conversation that triggered it.

A simple audit table looks like this:

CREATE TABLE ai_query_audit (
    id            BIGSERIAL PRIMARY KEY,
    actor_email   TEXT        NOT NULL,   -- the human, via OAuth
    ai_client     TEXT        NOT NULL,   -- claude, cursor, chatgpt...
    database_name TEXT        NOT NULL,
    sql_text      TEXT        NOT NULL,
    row_count     INTEGER,
    status        TEXT        NOT NULL,   -- 'allowed' | 'rejected'
    reason        TEXT,                   -- why rejected, if it was
    correlation_id UUID       NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_audit_actor_time ON ai_query_audit (actor_email, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

The broker writes one row per attempt — including the rejected ones, which are often the interesting ones. Now the earlier question has an answer:

-- Who touched customer 4821's data in the last week?
SELECT actor_email, ai_client, created_at, status, sql_text
FROM   ai_query_audit
WHERE  sql_text ILIKE '%customer_id = 4821%'
  AND  created_at >= now() - INTERVAL '7 days'
ORDER  BY created_at DESC;
Enter fullscreen mode Exit fullscreen mode
    actor_email      | ai_client | created_at          | status  | sql_text
---------------------+-----------+---------------------+---------+------------------------------------------
 dana@acme.com       | claude    | 2026-08-19 14:02:11 | allowed | SELECT * FROM orders WHERE customer_id...
 support@acme.com    | chatgpt   | 2026-08-18 09:41:55 | allowed | SELECT status, total FROM orders WHERE...
Enter fullscreen mode Exit fullscreen mode

You can build governance dashboards straight off this table — top queriers, rejected-write attempts over time, which tables get hit most, unusual after-hours activity. That's the observability layer security teams actually ask for.

Scoping access per team

Not everyone needs the same reach. Least privilege for AI access should be at least as sharp as the RBAC you give employees — arguably sharper, because a model will cheerfully try anything you let it. Two levers do most of the work: which databases an identity can see, and whether it's read-only.

A policy config for a broker might look like this:

roles:
  support:
    databases: [production_readonly]
    mode: read-only
    row_filter: "region = :user_region"   # row-level scoping
  analytics:
    databases: [production_readonly, events_warehouse]
    mode: read-only
  engineering:
    databases: [production_readonly, staging]
    mode: read-only
Enter fullscreen mode Exit fullscreen mode

Notice there's no write mode anywhere. For an AI exploration workflow, that's usually correct: the model can read the world and help you understand it, but it cannot modify a single row. If a prompt ever produces DELETE FROM subscriptions, the broker rejects it and logs the attempt rather than executing it.

Row-level filters are what make this safe for customer-facing use, too. Bind a filter like tenant_id = :current_tenant at the broker and a support agent asking "show me recent orders" only ever sees their own region's data — no matter how the model phrases the SQL.

What the workflow actually feels like

Governance shouldn't be visible to the person doing the work. From their side it's still plain English:

User: How many trial accounts converted to paid last month, by plan?

The AI client fetches the schema through the broker (so it uses real column names instead of hallucinating them), writes SQL, and the broker runs it read-only under that user's identity:

SELECT s.plan,
       COUNT(*) AS conversions
FROM   subscriptions s
JOIN   users u ON u.id = s.user_id
WHERE  u.trial_started_at >= date_trunc('month', now()) - INTERVAL '1 month'
  AND  u.trial_started_at <  date_trunc('month', now())
  AND  s.status = 'active'
GROUP  BY s.plan
ORDER  BY conversions DESC;
Enter fullscreen mode Exit fullscreen mode

The user gets their answer. Meanwhile, one row lands in ai_query_audit tagged with their email, the client they used, and a correlation ID. Nobody typed a credential; nobody can later ask "wait, who ran that?" and come up empty.

Common mistakes and gotchas

Logging the query but not the identity. A log full of anonymous SQL is barely better than none. The acting human's identity is the column that turns a log into an audit trail — capture it first.

Treating a shared service account as "the AI user." If every teammate's AI traffic authenticates as one account, you've rebuilt the connection-string problem with extra steps. One identity per person (or per agent), tied to your IAM.

Long-lived tokens in config files. Permanent keys can't be revoked cleanly and tend to leak. Prefer OAuth grants that expire and can be killed centrally the moment someone leaves.

Read-only in name only. "We told people not to run writes" is not a control. Enforce it at the broker so a rejected UPDATE is a logged event, not a trust exercise.

Ignoring shadow MCP. The moment a governed path exists, make it the only path. If people can still point tools straight at the database, your audit log has holes exactly where the risky queries are.

No retention or tamper-evidence on the log. An audit trail someone can quietly edit isn't much of an audit trail. Ship logs somewhere append-only, with a retention window that matches your compliance needs.

Key takeaways

Scattered connection strings give a team speed and take away accountability. You can keep the speed. Put a broker between AI clients and the database, make each person authenticate as themselves, keep access read-only and scoped by role, and log every attempt — allowed and rejected — with the identity attached. Whether you build that broker yourself or adopt a managed MCP server, the properties are the same: attributable, revocable, least-privilege, auditable.

The test is simple. If someone asks "who queried prod at 2 a.m. and why," you should be able to answer in one SELECT.

How does your team handle this today — shared credentials, a homegrown proxy, or a managed gateway? And what do you actually log per query? I'd love to hear what's working (and what's bitten you) in the comments.

Top comments (0)