Postgres read connector

The Postgres connector lets an AI agent read from your database without trusting it with your database. The agent can run a query you've allowed — "look up this customer's orders" — through a dedicated read-only role and RLS policy, the connector keeps reads inside the configured tenant scope, blocks writes, and never gives the agent your database password.

What it's for

Say you give a support agent the ability to answer "what's the status of order 1234 for Acme Corp?" To do that it needs to read your orders table. The naive way is to hand the agent a database connection string and hope it only runs SELECTs. But a confused or prompt-injected agent with that connection could just as easily run SELECT * FROM customers and exfiltrate everything, or UPDATE/DROP something.

This connector closes that gap. The agent asks Axtary to run a specific query; Axtary checks the query against your rules, runs it through a database login that is itself locked down to read-only, and hands back only the rows that login is allowed to see. The agent never holds the credentials and never gets a blank cheque against your data.

How it stays safe — two independent walls

Axtary doesn't rely on a single check. A write has to get past both of these to ever touch your data, and it's denied at the first:

1. Axtary inspects the query before connecting. It parses the SQL itself (not just string-matching) and only lets through a single SELECT. Anything that writes or could surprise you is refused before a connection is even opened: UPDATE/INSERT/DELETE, schema changes, table locks, sneaky write-inside-a-read CTEs, multiple statements stacked together, and queries against tables you didn't put on the allow-list. A query with no WHERE filter (an unscoped "read the whole table") isn't outright denied — it's held for a human to approve the exact statement first (this is a step-up).

2. The database login is locked down too. Even if a query passes Axtary, the connection runs as a dedicated Postgres role that you grant SELECT and nothing else, inside a read-only transaction, governed by row-level security (RLS) — a Postgres feature where the database itself filters which rows a login may see (e.g. "this login only ever sees tenant-a's rows"). So a write fails at the database, and a read can never escape its tenant, regardless of what query was sent.

That's the whole idea: Axtary enforces intent ("only this shape of read"), and the database enforces authority ("only these rows, never a write"). Neither alone is trusted.

What happens when an agent asks for data

Walk through a single request end to end:

  1. The agent proposes a query — e.g. SELECT id, label FROM public.axtary_scoped_items WHERE tenant_id = $1 with the value tenant-a. It sends this to Axtary; it does not connect to your database.
  2. Axtary parses and checks it. Is it a single SELECT? Are all the tables allow-listed? Is there a WHERE filter? Is the row cap respected? If any check fails, Axtary returns deny (or step_up for an unscoped read) and stops here — no connection is opened.
  3. Axtary connects as the locked-down role and runs the query inside a read-only transaction with the timeouts you set.
  4. The database applies row-level security, so even a broad query only ever returns the rows that login is allowed to see.
  5. Axtary returns the rows to your agent and writes a tamper-evident ledger entry recording that the query ran — but not the data itself.

The decision in step 2 is the fast path and the first wall; the role and RLS in steps 3–4 are the second wall that holds even if your rules are too loose.

Status: live. This connector is verified end to end against a real Postgres database — an allowed read returns only the permitted tenant's rows, a write is refused both by Axtary and by the database itself, and the exported audit record contains no connection string, query values, or returned rows.

Set it up

1. Create a locked-down reader role

Make a dedicated database login for Axtary — never reuse your application or migration user. Each line below is a deliberate restriction: the login can log in but owns nothing, can't create databases or roles, can't replicate, and can't bypass row-level security. It's granted SELECT on exactly one table, that table has RLS turned on, and the policy pins the login to a single tenant. The ALTER ROLE lines make every session read-only by default and cap how long a query or lock can run.

CREATE ROLE axtary_reader
  LOGIN
  NOSUPERUSER
  NOCREATEDB
  NOCREATEROLE
  NOREPLICATION
  NOBYPASSRLS;

GRANT CONNECT ON DATABASE appdb TO axtary_reader;
GRANT USAGE ON SCHEMA public TO axtary_reader;
GRANT SELECT ON public.axtary_scoped_items TO axtary_reader;

ALTER TABLE public.axtary_scoped_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY axtary_reader_tenant
  ON public.axtary_scoped_items
  FOR SELECT
  TO axtary_reader
  USING (tenant_id = 'tenant-a');

ALTER ROLE axtary_reader SET default_transaction_read_only = on;
ALTER ROLE axtary_reader SET statement_timeout = '5s';
ALTER ROLE axtary_reader SET lock_timeout = '1s';
ALTER ROLE axtary_reader SET idle_in_transaction_session_timeout = '5s';

In a real deployment, use your own tenant/RLS rule and a secret manager. The connection string (its DSN — the postgresql://user:password@host/db URL Postgres connects with) stays on the machine running Axtary and is referenced by an environment variable, never written into config or committed:

export AXTARY_POSTGRES_DSN='postgresql://axtary_reader:...@host/appdb?sslmode=require'

2. Point Axtary at it

The adapters block tells the local connector how to connect and what to allow; the policy block is the rule Axtary checks each query against. They overlap on purpose — the connector won't even open a connection for a table the policy hasn't allowed.

adapters:
  postgres:
    mode: postgres
    dsnEnv: AXTARY_POSTGRES_DSN          # the env var holding the DSN — not the DSN itself
    allowedTables: [public.axtary_scoped_items]
    allowedFunctions: []                 # SQL functions the agent may call (none by default)
    requireRls: true                     # refuse tables that don't have RLS enabled
    maxRows: 25                          # hard cap on rows returned

policy:
  postgres:
    reads:
      allowedDatabases: [postgres:database/appdb]
      allowedTables: [public.axtary_scoped_items]
      allowedFunctions: []
      maxRows: 25
      requirePredicate: true             # a read with no WHERE filter must be approved

3. Check it locally

Before wiring up an agent, confirm the role and table are configured the way Axtary expects. This reads only metadata (which role, is the transaction read-only, is RLS on) — never your data:

axtary doctor connectors --config examples/axtary.postgres.yml
axtary smoke --config examples/axtary.postgres.yml

The hosted dashboard deliberately does not run this check — your DSN never leaves the machine Axtary runs on, so readiness is something you confirm locally.

Run a governed read

This is what an agent's request turns into. Axtary decides, then (if allowed) executes through the locked-down role and returns the rows:

axtary run workflow postgres-read --real \
  --config examples/axtary.postgres.yml \
  --database appdb \
  --statement 'SELECT id, label FROM public.axtary_scoped_items WHERE tenant_id = $1 ORDER BY id' \
  --parameters '["tenant-a"]' \
  --max-rows 25

An allowed read comes back executed allow with just the permitted rows:

Axtary postgres-read workflow: completed
select_rows: executed allow (postgres_select_inside_policy)
database: postgres:database/appdb
tables: public.axtary_scoped_items
rows returned: 2

A read with no WHERE filter is held — it runs nothing until a human reviews the exact statement and re-runs with --approve-step-up:

Axtary postgres-read workflow: blocked
select_rows: blocked step_up (postgres_unpredicated_scan_step_up)
tables: none
rows returned: 0

A write (or anything that isn't a clean single SELECT) is denied — and no connection to your database was ever opened:

Axtary postgres-read workflow: blocked
select_rows: blocked deny (postgres_select_only)
tables: none
rows returned: 0

Verify what happened

Every decision and its outcome is written to a tamper-evident ledger. You can export and independently verify it — proving to an auditor (or yourself) that the records weren't edited after the fact:

axtary attest-ledger \
  --config examples/axtary.postgres.yml \
  --out .axtary/postgres-attestation.json

axtary verify-export .axtary/postgres-attestation.json

The record captures that a query ran and against which tables — fingerprints (hashes) of the statement and filter, the table and column names, the row count, and which role/database served it. It deliberately does not store the DSN, the parameter values, or the rows that came back, so the audit trail is safe to export and share.

Common mistakes

  • Pointing Axtary at your app's database user. That user can usually write and read everything, so the database's second wall is gone. Always create the dedicated axtary_reader-style role.
  • Forgetting RLS, or using a role that bypasses it. With requireRls: true, Axtary refuses a table that has no row-level security — but if your role has BYPASSRLS (or owns the table), Postgres ignores the policy entirely. The reader role must be NOBYPASSRLS and must not own the table.
  • Putting the DSN in axtary.yml. Config holds the name of the env var (dsnEnv), never the connection string. Keep the DSN in an env var or secret manager.
  • Expecting an unscoped read to "just work." A SELECT with no WHERE is intentionally held for approval. Either add a filter or approve it explicitly.

FAQ

Can the agent ever write to my database? No. A write is refused by Axtary before it connects, and the database role is read-only and lacks write grants, so even a write sent another way is refused by Postgres itself.

Can it read tables I didn't allow? No. Both the policy and the adapter carry an explicit table allow-list, and a query against anything else is denied before a connection opens.

Does my data or password ever leave my machine? No. The connection string and the returned rows stay in the local plane. The hosted dashboard never receives the DSN, and the ledger stores only hashes, names, and counts — never values.

Why was my query held for approval? It had no WHERE filter. Unscoped reads of a whole table are exactly the shape you want a human to glance at, so they step_up by default. Add a filter or approve the exact statement.

PostgreSQL references