Skip to main content
Glama
anunanuuu-wq

grantguard-mcp

by anunanuuu-wq
README.md
# grantguard

**Prove a Postgres role cannot perform the writes it is not allowed to perform.**

A prompt that says *"never change the owner or the due date"* is a request. A grant
that cannot express those columns is a fact. `grantguard` reads the facts out of
`pg_catalog`, compares them to a policy file you commit, and fails CI when they
disagree.

It is built for the case where something unattended holds a credential — an agent,
a cron job, a webhook handler — and the interesting question is not what it can do
but what it structurally *cannot*.

```
CRITICAL TABLE_WIDE_GRANT  public.tm_tasks UPDATE
         policy declares 2 column(s) (status, updated_at) but a TABLE-WIDE UPDATE
         grant is in force, so all 31 columns are writable. Any column-level grant
         on this table is decoration
         evidence: has_table_privilege('alis_dev_agent', 'public.tm_tasks', 'UPDATE') = true
         fix: revoke update on public.tm_tasks from alis_dev_agent;
              grant update (status, updated_at) on public.tm_tasks to alis_dev_agent
```

---

## Why this exists

In Postgres, a table-level `GRANT UPDATE ON t` applies to **every column**. A
column-level `GRANT UPDATE (a, b) ON t` applies to exactly those two. Hold both and
the table-level one wins — and the column list *stays in `pg_attribute.attacl`*,
where it goes on looking like a restriction that is no longer in force.

So this sequence, which looks entirely reasonable in a code review:

```sql
-- migration 1: it may move a status, nothing else
grant update (status, updated_at) on tm_tasks to agent;

-- migration 2, three days later: fix a 403 — "this only permits the verb"
grant update on tm_tasks to agent;
```

...silently widens one column to all of them. Reading the migrations in order
suggests the narrow grant still holds. Reading the catalogs says otherwise.

**This was found in production, not invented for a README.** An autonomous agent on
a multi-tenant platform I run was documented — in `CLAUDE.md`, in a registry, and in
the migration's own comment — as able to write *only* `status` on the work-queue
table. Measured against the live database:

| | before | after |
|---|---|---|
| columns the agent could `UPDATE` | **31** | **2** (`status`, `updated_at`) |
| `due_date` / `title` / `completed_at` / `assigned_to` | writable | denied |

*An owner, a due date, a status* are exactly the writes an unattended agent must
never make into a shared work system. Two of the three were open for three days,
and every document about the system said they were closed.

The distinction that catches it is cheap:

```sql
has_table_privilege(role, t, 'UPDATE')       -- true  => table-wide, every column
has_any_column_privilege(role, t, 'UPDATE')  -- true  => at least one column
```

Ask only the second and the widening is invisible. `grantguard` asks both, on every
verb, on every table, and then asks a policy file whether that is what you meant.

---

## Install

```bash
pip install grantguard              # Supabase backend, standard library only
pip install 'grantguard[postgres]'  # adds psycopg for --dsn
```

## Use it

**Look at what a role can actually write:**

```bash
grantguard describe --role agent --dsn "$DATABASE_URL"
```

```
role agent
  superuser=False  bypassrls=False  inherit=False  login=False

  public.tm_tasks
    SELECT  yes
    UPDATE  2 column(s): status, updated_at
    INSERT  10 column(s): assigned_to, created_by_name, description, due_date, …
    policy alis_dev_agent_update [UPDATE] to agent
      using      ((assigned_to = 'Anu Kama') AND (source = 'scope-agent'))
      with check ((assigned_to = 'Anu Kama') AND (source = 'scope-agent'))
```

**Generate a starter policy from live state** (the only way anyone adopts this on an
existing database — a blank file reports a hundred findings on the first run and
gets deleted):

```bash
grantguard init --role agent --out agents.yml
```

**Gate it:**

```bash
grantguard check --policy agents.yml --fail-on high
echo $?   # 0 clean, 1 findings at or above the threshold, 2 usage/connection error
```

---

## The policy file

```yaml
version: 1
schemas: [public]

roles:
  agent:
    description: Autonomous dev agent — moves its own task status, nothing else

    attributes:            # asserted, not assumed
      superuser: false
      bypassrls: false     # a BYPASSRLS role skips every policy on every table
      inherit: false

    tables:
      public.tm_tasks:
        select: true
        update: [status, updated_at]   # EXACTLY these columns
        insert: false
        delete: false
      public.tm_comments:
        select: true
        insert: true                   # `true` = table-wide is fine here

    never_writable:                    # must be impossible, not discouraged
      - public.tm_tasks.assigned_to    # who owns the work
      - public.tm_tasks.due_date       # when it is promised
      - public.tm_tasks.completed_at   # whether it is done

    allow_undeclared: false            # a table not listed above is a finding
```

Two deliberate choices:

- **A column list is a closed set, never a minimum.** Declaring
  `update: [status]` and finding a table-wide grant in force is the whole failure
  mode, so a list is always compared exactly.
- **`never_writable` is the inversion half.** Everything above it describes what
  should be allowed, and allowances drift open by accident. These name the writes
  that must be unreachable, so CI proves a negative instead of trusting that nobody
  widened a grant. It covers `INSERT` as well as `UPDATE` — writing a due date when
  *creating* a row is still writing a due date.

---

## What it checks

Twelve rules, ordered by the failure each one catches:

| Code | Severity | Catches |
|---|---|---|
| `ROLE_IGNORES_RLS` | critical<br>(advisory if declared) | `SUPERUSER`/`BYPASSRLS` — every policy on every table is skipped, so "the policy limits it to its own rows" is false |
| `NEVER_WRITABLE` | critical | a write declared impossible is reachable, and by which path |
| `TABLE_WIDE_GRANT` | critical | a column list was declared; a table-wide grant is in force |
| `ROLE_ATTR` | critical/high | an asserted role attribute does not match |
| `EXTRA_COLUMNS` | high | more columns writable than declared |
| `UNDECLARED_VERB` | high | a verb is granted that the policy says `false` |
| `UNDECLARED_TABLE` | high | privileges on a table the policy never mentions |
| `INHERITS_FROM_ROLE` | high | `INHERIT` + membership, so the audited table list is not the whole picture |
| `OWNER_BYPASSES_RLS` | high | the role owns the table, and an owner is exempt from its own policies unless RLS is `FORCE`d |
| `RLS_DISABLED` | high | row security off, so existing policies are inert |
| `MISSING_VERB` / `MISSING_COLUMNS` | medium | declared but not granted — a dead policy that fails as a flat 403 |
| `NO_POLICY` | medium | granted, RLS on, no policy names it: sees zero rows. Fails closed, reads as broken. Never reported for a role that bypasses RLS, where the claim would be false |
| `TRIGGER_ONLY` | advisory | a "cannot" that rests on a trigger rather than a privilege — drop the trigger and the write returns with no visible change to the grant |

Every finding carries the catalog fact it came from and a `fix:` you can paste.

**A declared `bypassrls: true` downgrades `ROLE_IGNORES_RLS` to advisory**, and this
matters more than it looks. `BYPASSRLS` is sometimes correct: a read-only reporting
role with `SELECT` and no policy does not get an error from an RLS-enabled table, it
gets **zero rows, silently** — so it can genuinely need `BYPASSRLS` to read at all
while holding no write anywhere. It stays critical when the file does not say so,
because the common case is nobody realising. But a gate that cannot be satisfied
gets waived with `--fail-on` and then ignored, taking every other check with it.
Saying it out loud in a reviewed policy is the difference between a decision and an
accident.


---

## In CI

```yaml
- run: pip install grantguard
- run: grantguard check --policy agents.yml --fail-on high
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
```

This repo's own CI does both directions against a real Postgres 16: it builds a
role with a deliberately widened grant and asserts `grantguard` **exits 1**, then
narrows the grant and asserts it **exits 0**. A gate that only ever fails is
indistinguishable from a gate that is broken.

---

## MCP server

```bash
grantguard-mcp --dsn "$DATABASE_URL"
```

```json
{ "mcpServers": { "grantguard": {
    "command": "grantguard-mcp",
    "args": ["--supabase-ref", "abcdefghijklmno"],
    "env": { "SUPABASE_PAT": "sbp_..." } } } }
```

Four read-only tools:

- **`explain_write`** — *can this role write this column, and by what path?* Names
  whether the privilege comes from a table-wide or a column grant, whether RLS can
  constrain it, and which policies apply. This is the one worth having: it answers a
  specific forbidden write with a fact instead of a guess.
- **`describe_role`** — the whole effective write surface.
- **`check_policy`** — the CI gate, inline or from a path.
- **`list_roles`** — every role plus which ones ignore RLS entirely.

Every tool is read-only, because `grantguard` only ever runs `SELECT`. A model
holding this server cannot change a grant — it can only be told the truth about
one, then tell a human. That is the right shape for a governance tool.

The server speaks JSON-RPC over stdio **directly, with no SDK**. Two reasons: a
security CLI should not make `pip install` pull pydantic, anyio and httpx to answer
four questions about `pg_catalog`; and the official SDK's server entrypoint moved
between majors (`mcp.server.fastmcp` is gone in 2.x). The wire protocol is small
and stable, the framework around it is not.

---

## Backends

**`--dsn` / `DATABASE_URL`** — a direct libpq connection, opened read-only so the
session itself refuses writes.

**`--supabase-ref` / `SUPABASE_PROJECT_REF`** with a PAT in `SUPABASE_PAT` — runs
over the Supabase Management API's SQL endpoint. This exists because a managed
Postgres often is not reachable from where CI runs: no open port, no pooler
credentials in the runner. Standard library only.

Two things about that API that read as an auth failure and are not:

1. `api.supabase.com` is behind Cloudflare, which **403s python-urllib's default
   User-Agent.** Send a browser one or every call looks like a bad token.
2. It returns Postgres arrays as **literal strings** (`{a,b}`), where psycopg
   returns Python lists. `tuple("{agent}")` is seven characters, so a naive read
   makes every role comparison fail *while the run looks completely healthy*. The
   first version of this tool reported "no policy" on a table with three policies
   for exactly that reason; `introspect.pg_array` now normalises both shapes and is
   unit-tested against quoted commas, escapes and `NULL` elements.

---

## What it does not do

Being clear about this matters more than the feature list:

- **It does not read your RLS expressions and tell you whether they are correct.**
  It reports that a policy exists, its command, and its `USING`/`WITH CHECK` text.
  Deciding whether `assigned_to = current_setting('...')` is the right rule is
  yours. Proving a *column* is unreachable is decidable; proving a *predicate* is
  sound is not.
- **It is not a runtime control.** It is an audit and a CI gate. Nothing here stops
  a write at the moment it happens — the grant does that, which is the point.
- **A token that can run SQL can run anything.** The read-only guard in
  `backends.py` stops a bug in *this tool* from writing to your database. It is not
  a security boundary and is not offered as one.
- **It does not audit `pg_hba.conf`, network reachability, or secret handling.** It
  answers one question about privileges inside the database.

---

## Design notes

**It reads catalogs, never migrations.** A migration records what somebody intended.
The catalogs record what is in force. The entire problem is that those drift apart
quietly, so a tool that parses SQL files would confidently reproduce the mistake it
is supposed to catch.

**The check layer never touches a database.** `introspect.py` turns Postgres into
plain dataclasses; `check.py` turns dataclasses into findings. That split is why the
38-test suite runs in 0.05s with no container, and why every rule has a test named
for the failure it prevents rather than the function it calls.

**Severity means "how does this fail".** Widening access is critical or high;
failing closed is medium. `MISSING_VERB` breaks your feature and protects your data,
so it does not belong in the same bucket as a table-wide grant. Grading a
fail-closed bug as critical is how a gate gets a `--fail-on advisory` waiver added
and then ignored.

## Development

```bash
pip install -e '.[dev]'
python -m pytest -q      # 38 tests, no database required
```

MIT licensed.

## Further reading

**[Your prompt says it can't set a due date. Your grant says it can.](docs/your-grant-says-it-can.md)**
— the incident this tool came out of, the one query that would have caught it, and
the four other ways a "cannot" leaks in Postgres. Every SQL snippet in it runs
without installing anything.