0016: One credential per target, replication when allowed, logical backups otherwise¶
Date: 2026-10-11 · Status: accepted
Context¶
A target used to name two users:
- credentials.secretRef: a read-only backup user;
- credentials.replicationSecretRef: a replication user for physical backups and the change stream.
The split doubled the setup (two users, two Secrets, two passwords to rotate) and bought almost no security:
- A Postgres user that may replicate can copy the whole cluster, since that is what a physical backup is.
- The read-only user already reads every table through pg_read_all_data.
Replication also needs things a database owner may not be able or willing to change right away: a pg_hba.conf rule,
the REPLICATION attribute, or MySQL's REPLICATION SLAVE/REPLICATION CLIENT grants. Until now a target without
them simply failed: Postgres physical backups errored, and the streamer reconnected forever.
Tenax does not own the database workload, so it must not edit pg_hba.conf or restart the server.
Decision¶
- One credential. A target has one
credentials.secretRef, used for every connection.replicationSecretRefis removed. This is a breaking change, made while the API isv1alpha1. An object that still sets it is rejected by the CRD schema. - Try replication, fall back without failing. Engines implement
engine.ReplicationChecker: - Postgres opens a replication connection. A pg_hba refusal (SQLSTATE 28000) or a missing
REPLICATIONattribute (42501) is a refusal. - MySQL checks
log_bin,binlog_format=ROW, and runs statements that needREPLICATION CLIENTandREPLICATION SLAVE, so grants through roles count. - Anything else (unreachable server, wrong password) stays an error. It never turns into a silent fallback.
- Full backups:
- Postgres in physical mode takes a logical backup (pg_dump) when refused.
- MySQL dumps without
--source-datawhen refused. - Both record a warning naming the reason and the exact fix. Restores follow each backup's own mode, so the fallback backups restore normally.
- Change stream:
- While refused, the streamer starts no chain, so no base backup is requested for a stream that cannot run. It
stays healthy and checks again every
controller.replicationRecheck(default 5 min). - Each check is recorded in
stream/access.json. - Once access works it starts the chain by itself, and the controller's existing chain-restart logic takes a fresh physical base backup.
- If access is revoked mid-chain, the streamer waits the same way. The slot keeps its WAL (bounded by
max_slot_wal_keep_size), so the chain continues if access returns in time and breaks cleanly if not. - Status:
- The controller mirrors
stream/access.jsoninto aReplicationcondition:ReplicationDeniedwith the fix in the message, orAllowed. - It also fills
status.pitr.replicationDenied,replicationFixandreplicationCheckedAt.
Consequences¶
- The quick start is one user and one Secret, plus an optional
pg_hbaline for point-in-time restore. - A target never stops backing up because replication is missing. It loses point-in-time restore, and says so in its condition, its backups' warnings and the streamer's log.
- Least privilege per connection type is gone. The one user can read everything and replicate, which is what the two users could do together.
- Existing targets must drop
replicationSecretRef. Thetenaxuser fromdocs/sql/has both sets of rights.