# Hardening

> Give your deployed app a database login that can serve your site but can never approve its own destructive actions.

*Limit what your deployed app's database login can do.*

> Needs the Postgres storage engine.

Your deployed app connects to Postgres with a username and password. If that
password leaks, or a bug in your app misuses it, whoever holds it can do
anything that database user can do. That includes approving its own deletions,
which would defeat [human approvals](/docs/audit-and-approvals).

`graft harden` fixes this by splitting access between two database logins:

- The **runtime role** is what your deployed app uses. It can serve content,
  run functions, and request approvals, but it can never grant one.
- The **operator role** stays on your machine or in CI. It compiles, migrates,
  merges, and approves.

This page sets that up in four steps, then explains exactly what it does and
does not protect.

## Harden a deployment

**Create an empty role for your app**

Connect as your usual admin user and create a new role with no privileges:

```sql
CREATE ROLE graft_runtime LOGIN PASSWORD '…';
```

It must start empty. The next step only adds permissions, it never removes
any, so a role that already has rights keeps them.

**Grant it Graft's permissions**

With the **operator** `DATABASE_URL` set, run:

```sh
graft harden graft_runtime
```

It prints every statement it applied. If the role does not exist yet, it stops
with the `CREATE ROLE` line you need.

**Check that approvals are locked**

Connect as `graft_runtime` and try to approve everything:

```sql
UPDATE approvals SET status = 'approved' WHERE status = 'pending';
```

You should get a permission error. If it succeeds, the role had rights before
you started. Check it is not a superuser and has no `UPDATE` on `approvals`:

```sql
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'graft_runtime';

SELECT has_table_privilege('graft_runtime', 'approvals', 'UPDATE');

SELECT has_column_privilege('graft_runtime', 'approvals', 'status', 'UPDATE');
```

All four should come back false. Both checks count rights the role inherits
from any group it belongs to, not only rights granted to it by name. The second
also catches an `UPDATE` granted on the `status` column alone, which is enough
to approve.

**Deploy with the runtime role**

Set your deployment's `DATABASE_URL` to the `graft_runtime` login. Keep the
operator URL out of the deployment, and use it only for `graft compile`,
`graft migrate --apply`, `graft merge`, and `graft approve`.

## What your app can still do

Everything a running site needs, so hardening costs you no features:

| Table | The runtime role may |
| --- | --- |
| Every Graft table | Read |
| `data_records` (operational data) | Add, change, and remove rows |
| `content_index` | Add and update documents, so agent writes can recompile |
| `compilations` | Add rows |
| `audit_log` | Add rows, and fill in a call's status and duration |
| `approvals` | File a request, using seven specific columns only |

It can also run `graft_consume_approval`, a database function that marks an
approved request as used. That is how an approved call goes through.

## What your app can never do

- **Change the schema.** No new tables, no migrations, no branch create or merge.
- **Approve anything.** It cannot change a request from pending to approved.
- **File a request that is already approved.** It can only write the columns a
  request needs (`branch_id`, `function_name`, `input`, `input_canonical`,
  `requested_by_kind`, `requested_by_id`, `correlation_id`). The `status` column
  is not one of them, so every new request starts as pending.

The last two only work together. If the role could insert the `status` column,
it could skip the approval step entirely by filing a request marked approved.

Every decision also records which database login made it, taken from the
database itself, so the audit trail shows which credential approved a request,
not which one claimed to.

## What hardening does not protect

Be precise about this one. The runtime role can update `content_index`, because
agent writes recompile content and compiling writes there. So someone with a
stolen runtime password could hide or rewrite documents with raw SQL.

That means the approval on `delete_content` protects you from an agent misusing
the tool, not from a stolen password. No set of permissions short of forbidding
content writes entirely could change that.

Approvals for destructive functions are different. Those are enforced by the
database, so they hold even against a stolen runtime password. That is the
guarantee hardening adds.

Your content is also still safe in Git. Recompiling restores the index.

## What changes after hardening

Some actions now need the operator login. That is the point:

| Action | On a hardened deployment |
| --- | --- |
| `graft approve` / `graft deny` | Operator login only |
| Approving over MCP (`decide_approval`) | Refused, so agents cannot approve |
| Approve and deny in Studio | Refused, so the approvals view is read-only |
| Approvals asked inside the agent's session | Always refused |
| An agent writing content | Still works |
| `graft migrate --apply` | Operator login only |

## The Graft container does this for you

The self-host container creates and hardens a runtime role when it starts, then
serves with it, because in that setup the container owns its database.

| Mode | Hardening |
| --- | --- |
| All-in-one | On by default |
| `GRAFT_MODE=serve` (your own database) | Off, until you set `GRAFT_HARDEN=1` or `GRAFT_RUNTIME_PASSWORD` |

In `serve` mode the database is yours, so the container does not create roles in
it unless you ask. [Configuration](/docs/configuration#serve) lists these
variables.

## Next steps

- [Audit & approvals](/docs/audit-and-approvals) explains the approvals this
  protects.
- [Deploy](/docs/deploy) shows where each login goes.
- [Studio](/docs/studio) lists approvals under Developer, read-only on a
  hardened deployment.
