{"slug": "our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our", "title": "Our AI assistant can write to exactly one table. Postgres enforces it, not our code", "summary": "The team behind Reperline, a construction scheduling and budgeting system, built an MCP-connected Claude assistant whose database access is constrained by Postgres rather than application code: the MCP server role can only SELECT under row-level security and INSERT into a single pending_changes table, with no UPDATE or DELETE grants, and changes are applied only by a human approver through a SECURITY INVOKER function. The team also documented a migration bug in which revoking EXECUTE on sensitive functions from the MCP role did nothing because Postgres grants EXECUTE to PUBLIC by default, fixed by revoking from PUBLIC and granting to the app role, and recommended has_function_privilege checks in CI.", "body_md": "We build [Reperline](https://reperline.com/?utm_source=devto), a small system that tracks construction schedules, budgets and contracts. It also connects to Claude through MCP (Model Context Protocol), so a project manager can ask \"which milestones are late?\" or upload an invoice and have Claude prepare the entry.\n\nThat second part is where it gets uncomfortable. Budget data is money. We did not want \"the AI only proposes changes\" to be a promise made by application code that one refactor could break. We wanted the database to make it impossible.\n\nThis post is how we set that up in Postgres, and the one default that quietly undid part of it.\n\nThe web app and the MCP server connect as different Postgres roles. Neither has `BYPASSRLS`.\n\n`app_rw` is the web app. It reads and writes, filtered by row level security.`app_mcp` is the MCP server. It can `SELECT` (RLS still applies) and it can `INSERT` into one table: `pending_changes`.\n\n```\ngrant insert on pending_changes to app_mcp;\n\ncreate policy pending_changes_mcp_ins on pending_changes for insert to app_mcp\n   with check (org_id = current_org_id() and status = 'pending' and source = 'claude');\n```\n\nThere is no `UPDATE` or `DELETE` grant for `app_mcp` anywhere. So even if a tool handler had a bug, or a prompt talked the model into trying, the worst it can do is file a row that says \"pending, from Claude\" in its own organisation. A person approves or rejects it in an inbox. The function that applies it, `apply_pending_change`, is executable by `app_rw` only and is `SECURITY INVOKER` on purpose: it runs with the approver's own row level security, so a person can only approve a change they would be allowed to make themselves.\n\nSome writes in our system go through functions, for example applying an imported schedule. In an early migration we wrote, roughly:\n\n```\nrevoke execute on function apply_schedule_import(uuid) from app_mcp;\n```\n\nIt did nothing. In Postgres a new function is executable by `PUBLIC` by default, and every role is a member of `PUBLIC`. Revoking from `app_mcp` removes a grant that `app_mcp` never had on its own; the `PUBLIC` grant still lets it in.\n\nThe fix was the next migration:\n\n```\nrevoke all on function schedule_import_diff(uuid), apply_schedule_import(uuid) from public;\ngrant execute on function schedule_import_diff(uuid), apply_schedule_import(uuid) to app_rw;\n```\n\nSince then, sensitive functions are revoked from `public` and granted to the one role that needs them. If you rely on roles to limit an AI agent (or anything else), check functions as well as tables. `has_function_privilege('app_mcp', 'apply_schedule_import(uuid)', 'execute')` is a quick test to put in CI.\n\nOur roles are configurable per organisation, so policies cannot list role names. A permission is an (area, read or write) pair, and policies call `has_perm(area, mode)`, a `SECURITY DEFINER` function.\n\nOne small detail mattered: we wrap it in a scalar subquery.\n\n```\nusing ((select has_perm('budget', 'r')))\n```\n\nWritten bare, the planner may call the function for every row. As `(select ...)` it does not depend on the row, so Postgres evaluates it once per statement as an init plan.\n\nPostgres combines ordinary (permissive) policies with OR. That is right for \"owner or finance may read\", and wrong for rules that must apply on top of everything, like \"only the projects you were given\" or \"a trial that ended is read only\".\n\nThose are `RESTRICTIVE` policies, which are combined with AND. We generate them per table:\n\n```\ncreate policy budget_lines_proj on budget_lines as restrictive\n for all to app_rw, app_mcp using (<project is visible>) with check (<project is visible>);\n\ncreate policy budget_lines_w_ins on budget_lines as restrictive\n for insert to app_rw, app_mcp with check (org_writable(current_org_id()));\n```\n\nA new permissive policy added later cannot widen access past them, which is exactly what you want from a rule like \"read only after the trial\".\n\nThe weak spot of RLS is the table someone adds next month and forgets to protect. Our isolation test does not keep a list. It reads the catalogue for every table and view with an `org_id` column, creates two organisations, and checks, as both `app_rw` and `app_mcp`, that a user of A cannot read, update, delete or insert anything of B. A new table is covered the moment it exists, and fails the test run if its policies are missing.\n\nGrants and policies decide who can change what. They cannot decide whether Claude's proposal is right. An invoice read wrongly is still a wrong proposal; it just cannot reach the books without a person looking at it. The inbox is where the real control happens, and the database only guarantees that nobody can skip it.\n\nIf you are connecting an LLM to data that matters, our one piece of advice: give it its own database role, list exactly what that role may write, and test it the way an attacker would, including functions.", "url": "https://wpnews.pro/news/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our", "canonical_source": "https://dev.to/appwebseo/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our-code-4pp7", "published_at": "2026-11-14 13:05:58+00:00", "updated_at": "2026-11-14 13:21:09.829721+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-tools", "developer-tools"], "entities": ["Reperline", "Claude", "Postgres", "Model Context Protocol"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our", "markdown": "https://wpnews.pro/news/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our.md", "text": "https://wpnews.pro/news/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our.txt", "jsonld": "https://wpnews.pro/news/our-ai-assistant-can-write-to-exactly-one-table-postgres-enforces-it-not-our.jsonld"}}