Hardening

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

Deploy & operate4 min read

needs Postgres

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

  1. 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.

  2. Grant it Graft's permissions

    With the operator DATABASE_URL set, run:

    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.

  3. Check that approvals are locked

    Connect as graft_runtime and 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 UPDATE on approvals:

    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.

  4. 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:

TableThe runtime role may
Every Graft tableRead
data_records (operational data)Add, change, and remove rows
content_indexAdd and update documents, so agent writes can recompile
compilationsAdd rows
audit_logAdd rows, and fill in a call's status and duration
approvalsFile 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:

ActionOn a hardened deployment
graft approve / graft denyOperator login only
Approving over MCP (decide_approval)Refused, so agents cannot approve
Approve and deny in StudioRefused, so the approvals view is read-only
Approvals asked inside the agent's sessionAlways refused
An agent writing contentStill works
graft migrate --applyOperator 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.

ModeHardening
All-in-oneOn 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.