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:
- The agent proposes a query — e.g.
SELECT id, label FROM public.axtary_scoped_items WHERE tenant_id = $1with the valuetenant-a. It sends this to Axtary; it does not connect to your database. - Axtary parses and checks it. Is it a single
SELECT? Are all the tables allow-listed? Is there aWHEREfilter? Is the row cap respected? If any check fails, Axtary returnsdeny(orstep_upfor an unscoped read) and stops here — no connection is opened. - Axtary connects as the locked-down role and runs the query inside a read-only transaction with the timeouts you set.
- The database applies row-level security, so even a broad query only ever returns the rows that login is allowed to see.
- 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 hasBYPASSRLS(or owns the table), Postgres ignores the policy entirely. The reader role must beNOBYPASSRLSand 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
SELECTwith noWHEREis 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.