What a Compromised Processor Can Do¶
Every security design should be able to answer "and what if this component is fully compromised?" without flinching. This chapter answers it for the Processor: assume an attacker has complete control of the Processor host — its process, its environment, its files — and ask what that actually buys them.
The honest answer is not much, and this chapter is careful to say precisely how much. It assumes the Processor is deployed as this book recommends: on its own host, connecting as the pgrelay role over TCP. (On a co-located sandbox where the Processor runs as the postgres OS user, the answer is instead everything — which is the whole argument of Where to Run the Processor.)
What the attacker holds¶
Three things, and only three:
- The
pgrelayrole's database credential — a.pgpassentry or a short-lived cloud identity token — valid only from the Processor host's address ifpg_hba.confand the network are configured as Database-Side Controls describes. - The outbound provider secrets in the Processor's environment — an SMTP password, a Graph or ACS client secret, a Gmail refresh token, webhook bearer tokens, an OTLP receiver token. Whatever
_env:variables the notifier profiles reference. - Whatever flows through the Processor while they watch — the content of notifications as they are fetched and sent, and the payloads of events as they are processed.
Not held: any other database credential, any table's contents, any superuser or OS-level access to the database host (it is a different machine, and on a managed platform does not even exist as a host), or any way to make the database do something it would not do for the pgrelay role.
What the pgrelay role can do¶
The role is granted exactly the "operating set" — the functions the Processor needs to do its job — listed in Granting Permissions. Read as an attacker's toolkit, that set allows:
| Capability | How | Impact |
|---|---|---|
| Fire events on any active, unreserved channel | pgrelay.notify() — granted to PUBLIC by default, so this is true of any role that can connect, not specifically pgrelay |
Triggers registered actions with an attacker-chosen payload. For a 'sql' channel the payload is inert $1 data, so this runs the action as designed with unexpected input — the same exposure as any application bug that calls notify() wrongly. For a notification channel the payload is a notifier record id, so an attacker who can guess ids can re-send existing notifications |
| See every pending event, and process it | queue_pending_ids(), _queue_claim(), process_one() |
This is the Processor's job; an attacker can do it early, selectively, or not at all. Registered SQL actions run as pgrelay (process_one is SECURITY INVOKER), with whatever privileges that role has in your schemas |
| Discard pending events | _queue_mark_done() / _queue_delete() on any id they can see |
The most significant capability: a denial of processing. Events can be marked done without running, or deleted. Since a rogue Processor could equally just stop processing, this is the same outcome by another route — but it is silent, and it is why the audit trail matters (below) |
| Write misleading audit rows | _write_log() |
The audit log can be polluted with fabricated ok or error rows. It cannot be edited — rows are insert-only through this function — so genuine history survives; the risk is noise, not erasure |
| Insert retry rows | _queue_insert_retry() |
Duplicate or spurious retries of events on channels that allow retries |
| Read every notification's content | pgrelay_notifier.fetch() (granted by the notifier's install) |
Any notification record the attacker can name by id — recipients, subject, body, attachments. Together with the provider secrets, this is the meaningful confidentiality exposure: the Processor necessarily sees what it sends |
| Trigger fleet reloads, write health rows | request_reload(), _write_health() |
Nuisance-level: a reload storm, or fabricated health snapshots |
Run ad-hoc SQL as pgrelay |
The pg_relay.exec lane, or simply a direct connection with the same credential |
Bounded entirely by what the pgrelay role — and any role GRANTed to it — can do; see the next section |
| Use the provider secrets | They are in the environment | Send mail as your configured sender, post to your webhooks, push metrics — until the secrets are rotated |
What the pgrelay role cannot do¶
This list is the reason the design holds up:
- Read or modify pg_relay's own tables directly.
pgrelay.queue,pgrelay.log,pgrelay.actions,pgrelay.action_types,pgrelay.processor_health,pgrelay.optionsall have every privilege revoked fromPUBLICand are not granted topgrelay. The only reads are the narrow ones the functions above perform for the role. An attacker cannot dump the queue's history or enumerate channel configuration. - Change any channel or job.
register(),update(),unregister(),enable()/disable(),schedule_job(),run_sql(),cancel(),pause_job()and the rest are management functions granted throughgrant_user()to management roles — never topgrelay. The attacker cannot repoint an action at different SQL, register a new channel, or schedule new work. - Reach the reserved ad-hoc SQL channel.
notify()refuses reserved channels, and the functions that can enqueue onto one (schedule_job(),run_sql()) are management-only. The attacker cannot submit new SQL for execution through the queue; they can only run SQL directly aspgrelay, which brings us to: - Do anything in your schemas beyond
pgrelay's own privileges. Outside thepgrelayschema, the role has whateverPUBLIChas in your database plus anything you granted it explicitly. Keep that at nothing (see Database-Side Controls) and a direct connection aspgrelaycan read no application table at all. - Become another role — except one you deliberately
GRANTed topgrelayforrun_asjobs. This is the single most important knob in the whole design, and it deserves its own section. - Connect from anywhere else. With
pg_hba.confrestrictingpgrelayto the Processor hosts' addresses (and a cloud security group doing the same), the credential is worthless off the host. - Reach anything the egress allowlist does not permit. The provider secrets can only be used against the destinations the Processor host can reach.
- Escalate on the database host. It is a different machine (or, in the cloud, not a machine you can reach at all).
The one knob that sets the blast radius: GRANT … TO pgrelay¶
Ad-hoc SQL jobs (pg_relay.exec) can run SET ROLE <run_as> so that a maintenance job executes as a role that owns the tables it must VACUUM or REINDEX. For that to work, the DBA must first GRANT <run_as> TO pgrelay — a deliberate, per-role opt-in.
Every role you grant this way is a role a compromised Processor can become, directly, with a plain SET ROLE, no queue involved. So:
- Grant narrow, purpose-built maintenance roles — one that owns exactly the tables a job maintains — never an application role, and never anything with
CREATEROLE,CREATEDB,BYPASSRLS, or ownership of schemas you care about. - Treat the list of roles granted to
pgrelayas a security-reviewed inventory.SELECT roleid::regrole FROM pg_auth_members WHERE member = 'pgrelay'::regrole;shows it. - If no ad-hoc job needs
run_as, grant nothing. The Processor then can never be anything butpgrelay.
With that list empty and pgrelay holding no privileges in your schemas, the worst a full Processor compromise can do to your data is: read the notifications it was going to send anyway, fire or suppress events, and pollute the audit log with extra rows. It cannot read your tables, cannot change your data except through actions you registered, cannot alter pg_relay's configuration, and cannot touch the database host.
Detection¶
A compromised Processor is not silent from the database's point of view — everything it does leaves marks it cannot remove:
pgrelay.logis insert-only. Fabricated rows can be added; genuine rows cannot be changed or deleted bypgrelay. Uselog_report()andqueue_stats()to watch for events resolving asokimplausibly fast, an unexpected volume oferrorrows, or channels going quiet while their producers are active.pg_stat_activityshows everypgrelaysession with itsclient_addr. A session from an address that is not a Processor host is an alarm.log_connections = onfor the role gives you a durable record.- Instance slots. Each Processor stamps a slot number (1–64) into every log line; an instance count in
journalctlthat does not match the fleet you run means something else is holding a slot. - Lifecycle notifications.
PG_RELAY_STOP_NOTIFYfires on any deliberate exit andPG_RELAY_OUTAGE_NOTIFYwhen the database becomes unreachable — an attacker stopping the real Processor to substitute their own triggers the first; an attacker locking the real Processor out triggers the second. See Lifecycle Notifications. - Provider-side signals. Unexpected sends from your configured sender, webhook posts, or metric points are visible at the provider, and are the reason to scope provider credentials narrowly (a send-only mailbox, a token limited to one webhook) so that the damage is bounded even before you notice.
Containment¶
Because everything the Processor can do depends on one role and one set of secrets, containment is short:
- Lock the role out:
ALTER ROLE pgrelay NOLOGIN;thenSELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'pgrelay';. Every Processor — legitimate or not — is now disconnected and cannot return. (Note thatpgrelay.stop()is not the kill switch here: pausing the fleet is honoured by the real Processor's code, not by an attacker's own client.) - Rotate the database credential — or, with identity-based cloud auth, revoke the host's identity.
- Rotate every provider secret the environment held. They are all listed in the environment file; nothing else references them.
- Review the audit log for the compromise window with
log_report(), and the queue withqueue_pending(), before re-enabling anything. - Rebuild the Processor host from a known image — it holds no state worth preserving, which is the point.
ALTER ROLE pgrelay LOGIN;and restart the fleet.
The queue itself is untouched by all of this: events enqueued during the outage wait durably, and retry rows are already part of the normal flow. Nothing needs to be replayed by hand.
Continue to Hardening the Service.