Skip to content

feat: enforce a read-only policy per database role - #3

Merged
mickamy merged 5 commits into
mainfrom
feat/policy
Aug 27, 2026
Merged

feat: enforce a read-only policy per database role#3
mickamy merged 5 commits into
mainfrom
feat/policy

Conversation

@mickamy

@mickamy mickamy commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

Turns the always-allow handler into a real policy. A YAML file maps each database role to an agent and can mark it read-only; the proxy resolves the policy to a per-session handler after the handshake and refuses disallowed statements before they reach the server. Without -policy, every statement is still allowed.

What is in here

  • internal/sqlscan: a lexical statement classifier. It tokenizes far enough to skip strings, line and block comments, dollar-quoted bodies, and quoted identifiers, then classifies each top-level statement by its leading keyword. It is deliberately conservative for read-only: writes hidden in EXPLAIN ANALYZE, data-modifying CTEs (WITH d AS (DELETE ...)), and COPY ... FROM are treated as mutating, and an unrecognized statement fails closed.
  • internal/policy: loads the YAML file (fail: open|closed, roles keyed by database user with agent, purpose, read_only), and implements wire.Guard. A read-only role denies any mutating statement with an ErrorResponse written for an LLM to act on. Unlisted roles are allowed under fail-open and denied under fail-closed. The zero-value policy allows everything.
  • internal/wire: adds the Guard interface (Resolve(Startup) Handler) and AllowAll.
  • internal/proxy: resolves the guard to a per-session handler once the handshake yields the client's identity.
  • internal/cli: a -policy PATH flag.

Dependency

Adds gopkg.in/yaml.v3 — the first dependency, for the policy file. Chosen over JSON so the file can carry comments and read naturally.

Testing

  • make test and make lint pass.
  • internal/sqlscan: classification of each statement kind, keywords hidden in literals/identifiers/comments/dollar-quotes, data-modifying CTEs, EXPLAIN with and without ANALYZE, and COPY direction.
  • internal/policy: parsing (including rejection of unknown fields and bad fail values), read-only denial and its message, writable roles, and fail-open vs fail-closed for unlisted roles.
  • Verified against postgres:16: a read-only role's SELECT reaches the database while UPDATE, EXPLAIN ANALYZE DELETE, and WITH ... DELETE are refused before it, leaving the data unchanged; a writable role's UPDATE succeeds.

Not in this PR

Table/column allowlists and row limits (they need column-level parsing), purpose and session from SQL-comment tags, writes: approval, and the access ledger.

Review round: hardened read-only enforcement

A review found that lexical read-only classification alone is bypassable. Addressed:

  • Classifier bypasses fixed (internal/sqlscan): line comments now end at \r as well as \n; $ inside identifiers no longer starts a false dollar-quote; COPY ... FROM ... WITH (...) and COPY ... TO a file or program are classified as writes (only COPY ... TO STDOUT reads); EXPLAIN ANALYZE CREATE TABLE AS and SELECT ... INTO are classified as writes. The tokenizer now keeps quoted identifiers distinct so a quoted GUC name is comparable.
  • Defense in depth for the actual guarantee: a read-only session is primed with SET default_transaction_read_only = on, so the server refuses writes made through functions (nextval, setval, volatile user functions) that no lexical layer can see. Verified against postgres:16: SELECT setval(...) on a read-only role is refused by the server.
  • Escape hatches blocked: the proxy refuses SET/RESET of the read-only GUCs (quoted or not), SET TRANSACTION READ WRITE, BEGIN/START ... READ WRITE, RESET ALL, and set_config(...).
  • Interface: wire.Guard now returns an Enforcement{Prime, Handler}; wire.Session gains Prime, run after the handshake and before the relay.

Documented residual: a role that already holds write privileges is not fully sandboxed by a lexical proxy; the strongest read-only control is a database role granted only SELECT. rollcall prevents accidental and obvious writes, hardens against function writes via the server, and blocks the common escape hatches.

@mickamy
mickamy merged commit dfb7e3e into main Aug 27, 2026
8 checks passed
@mickamy
mickamy deleted the feat/policy branch August 30, 2026 01:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant