Skip to content

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. replicationSecretRef is removed. This is a breaking change, made while the API is v1alpha1. 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 REPLICATION attribute (42501) is a refusal.
  • MySQL checks log_bin, binlog_format=ROW, and runs statements that need REPLICATION CLIENT and REPLICATION 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-data when 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.json into a Replication condition: ReplicationDenied with the fix in the message, or Allowed.
  • It also fills status.pitr.replicationDenied, replicationFix and replicationCheckedAt.

Consequences

  • The quick start is one user and one Secret, plus an optional pg_hba line 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. The tenax user from docs/sql/ has both sets of rights.