使用说明
Database Security Framework
How I gave an AI agent database access without giving it the keys: row-level security on every table, read-only queries, and a write path that only touches what I allow.
An agent that runs financial workflows, compliance checks, and content operations has to read broadly and write carefully. It needs SELECT across dozens of tables. It gets INSERT on a handful, UPDATE on a few columns, and DELETE on 3 tables with a WHERE clause.
Most AI-to-database setups pick full access or no access. Full access is dangerous. No access is useless. This is the middle, built on a Supabase Postgres schema of 59 tables.
An early audit found 8 real errors in a system that looked secure from every angle I had checked. 5 tables had no row-level security. 3 views ran with their creator's permissions and skipped RLS. RLS was "enabled" in every design document. It just wasn't enabled on those 5 tables. The fix took minutes. Finding the gaps took a method that checks each table and each view.
Then it happened again, in a smaller way. See the views that skipped RLS.
What's here
| File | What it is |
|---|---|
| sql/rls-policies.sql | Row-level security state and policies, from the live schema. |
| sql/rls-audit.sql | The audit queries: tables without RLS, views that skip it, what the public roles can reach, default privileges. |
| config/write-allowlist.json | The tables and columns the write service may touch, trimmed to tables that exist today. |
| workflows/data/aos-database-safe-sql-query.json | The read-only query service. Sanitized n8n export. |
| workflows/data/aos-database-safe-db-write.json | The allowlisted write service. Sanitized n8n export. Being rebuilt. See the note below. |
| LICENSE | MIT. |
sql/rls-policies.sql leaves out the agency_ tables, which belong to a separate client-facing system. All 59 tables have RLS on.
Note on the write workflow
The Safe DB Write workflow is published as a record of the design. It is being rebuilt. Its final database node, an n8n Postgres node at version 2.5, does not commit writes. The workflow runs, validates, and reports success, and the data doesn't change. I found this in testing and haven't trusted the workflow since. Writes go through the Supabase REST API today, with the same allowlist. The validation code in the export is the design that carries forward. The write step is what's broken.
The export also still lists tables from a retired project. The trimmed list in config/write-allowlist.json matches what exists today.
Architecture
Agent request
|
Input router
handles MCP and webhook payloads
|
Operation classifier
/ \
Safe SQL query Safe DB write
- SELECT only - table allowlist
- 9 statement - column allowlist on UPDATE
types blocked - DELETE needs a WHERE
- 50-row cap and never a bulk pattern
\ /
Postgres + row-level security
- RLS on every table
- one service-role policy per table
- a few named app roles with narrow policies
Input router. n8n receives a request differently depending on how it was called. An MCP call puts the text in chatInput. A webhook puts it in body.query. The validation step checks both before any database step runs.
Safe SQL query (read path). A webhook that runs SELECT and nothing else. It blocks 9 statements: insert, update, delete, drop, alter, truncate, create, grant, revoke. It returns at most 50 rows.
Safe DB write (write path). A webhook with per-table permissions, as configured in the export:
- INSERT is allowed on 12 tables. There is no column list on INSERT.
- UPDATE is allowed on 16 tables, each with a list of columns it may change.
- DELETE is allowed on 5 tables. It needs a WHERE clause and rejects
where trueandwhere 1=1. - DROP, ALTER, TRUNCATE, CREATE, GRANT and REVOKE are always blocked.
- Of those tables, 5 for INSERT, 8 for UPDATE and 3 for DELETE exist today. The rest belonged to a retired project.
- One table used only by a separate business line is omitted from the published config.
Row-level security. RLS is on for all 59 tables. Service-role policies control what the agent reaches. Table prefixes mark who owns each table: aos_ for operations, agency_ for client work, haios_ for the human-AI coordination layer.
What the audit found
The design documents said RLS was enabled and access controls were in place. So I stopped reading documents and checked the database, table by table and view by view. It found 8 errors:
- 5 tables with no RLS at all
- 3 views with SECURITY DEFINER, which run with the creator's permissions and not the caller's
These were production gaps in a system that had run for months. "Secured" is a claim. Each table and each view has to prove it.
The views that skipped RLS
A later audit cleared 4 views on their content. They showed names with the email masked and left the raw text out, and I judged them safe because of what they exposed.
I had checked what the views showed and skipped how they ran. Views run with their creator's rights unless you say otherwise, so they skip row-level security. Anyone holding the public API key could read them, and their grants looked broad enough that writes through them were likely too.
On 2026-09-29 I set security_invoker on all 4 and revoked access from the public roles. Then I checked from the outside: the public key now gets permission denied on every one. I also changed the default privileges so new tables created by the main role no longer grant anything to the public roles automatically.
The sql/rls-audit.sql queries now include a check for this: views without security_invoker, and what each public role can actually reach.
The lesson repeats. Review what an object exposes and also how it runs.
The bug between 2 systems
Another find came from intermittent query failures. Sometimes a query worked and sometimes it came back empty. The SQL was right. The logic was right.
The cause was how n8n hands over the request. An MCP call and a webhook call put the query text in different places. The failure only showed when I changed how I called the workflow, and nothing at either layer pointed at it.
The fix is one line that checks both places:
$input.first().json.body?.query || $input.first().json.chatInput || ''
Numbers
| Item | Value |
|---|---|
| Production tables | 59, with RLS on all 59 |
| Blocked statements on the read path | 9 |
| Read-path row limit | 50 |
| Write allowlist as configured | 12 INSERT, 16 UPDATE, 5 DELETE tables |
| Write allowlist that exists today | 5 INSERT, 8 UPDATE, 3 DELETE tables |
| Errors found by the early audit | 8 (5 missing RLS, 3 SECURITY DEFINER views) |
Why this matters for security work
Least privilege for an AI agent is the same problem as least privilege for any account. Which identity gets which resource, at which operation level, under what conditions. Allowlist by table, restrict by column, require a WHERE clause on anything destructive. That is the standard enterprise model, applied to a new kind of user.
The part that carries over is the audit method. Check the actual state of every object and ignore the documented intent. That is where real security failures sit, in the gap between what the architecture says and what the system does.
Built with
Supabase (Postgres and pgvector), row-level security, n8n webhooks, and the Model Context Protocol.
Jordan Waxman · LinkedIn · GitHub profile