Hardening
Limit what your deployed app's database login can do.
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.
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:
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.
With the operator
DATABASE_URLset, run:graft harden graft_runtimeIt prints every statement it applied. If the role does not exist yet, it stops with the
CREATE ROLEline you need.Check that approvals are locked
Connect as
graft_runtimeand try to approve everything: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
UPDATEonapprovals: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
UPDATEgranted on thestatuscolumn alone, which is enough to approve.Set your deployment's
DATABASE_URLto thegraft_runtimelogin. Keep the operator URL out of the deployment, and use it only forgraft compile,graft migrate --apply,graft merge, andgraft 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). Thestatuscolumn 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 lists these
variables.
Next steps
- Audit & approvals explains the approvals this protects.
- Deploy shows where each login goes.
- Studio lists approvals under Developer, read-only on a hardened deployment.