Skip to content

Support user-supplied pg_hba rules via configSecret key user_hba.conf - #68

Merged
souravbiswassanto merged 1 commit into
masterfrom
custom-pg-hba
Aug 25, 2026
Merged

Support user-supplied pg_hba rules via configSecret key user_hba.conf#68
souravbiswassanto merged 1 commit into
masterfrom
custom-pg-hba

Conversation

@tamalsaha

Copy link
Copy Markdown
Member

Users have had no way to add pg_hba.conf rules: the role scripts regenerate the file from scratch on every pod start, and since pg_hba.conf is first-match-wins, appending at the end (the analogue of postgresql.conf's trailing include_if_exists, which is last-wins) would leave user rules dead behind the generated host all all 0.0.0.0/0 catch-alls.

This splices /etc/config/user_hba.conf (projected by the operator from the Postgres configSecret) into every generation branch at a fixed position: after the local/loopback and loopback-replication rules the operator's own scripts and sidecars depend on, and before the world-CIDR catch-alls. User rules can override the catch-alls — e.g. reject the postgres role from outside the pod network — but cannot lock the operator out.

Version-gated mechanism (both paths verified empirically):

  • PG ≥ 16: emits include_if_exists "/etc/config/user_hba.conf" — secret updates apply on pg_reload_conf(), no restart; missing file tolerated at startup and reload. The double quotes are load-bearing: a single-quoted token is treated as a literal filename and skipped silently.
  • PG ≤ 15: an include line is FATAL: could not load pg_hba.conf at startup (verified on 15-alpine), so the file content is concatenated at generation time; changes apply on pod restart.

Scope: primary/start.sh, standby/run.sh, standby/remote-replica.sh (long-running servers). warm_stanby.sh/ha_backup_job.sh/restore.sh are deliberately untouched — they run postgres for seconds during bootstrap/restore, where a user rule breaking recovery costs more than a seconds-long window of default rules protects.

Verification: a harness executed the generation section of all 26 modified scripts and their master counterparts under all five SSL/CLIENT_AUTH_MODE combos, with and without the user file — 260/260:

  • file absent: PG ≤ 15 output byte-identical to master; PG ≥ 16 differs by exactly the include line
  • file present: rules appear exactly once, after the essentials, before the first catch-all, in every branch

Companion PRs: kubedb/apimachinery (constant), kubedb/postgres (projects the key), kubedb/docs (guide, including the pod-CIDR-allow-before-reject pattern that keeps pg-coordinator's peer connections as postgres working).

pg_hba.conf is regenerated from scratch by these scripts on every pod
start, so users have had no way to add authentication rules -- and
because pg_hba.conf is first-match-wins, appending rules at the end
(the natural analogue of postgresql.conf's trailing include_if_exists,
which is last-wins) would leave them dead behind the generated
"host all all 0.0.0.0/0" catch-alls.

Instead, rules from /etc/config/user_hba.conf (projected by the
operator from the Postgres configSecret) are injected into every
generation branch at a fixed position: after the local/loopback and
loopback-replication rules the operator's own scripts and sidecars
depend on, and before the world-CIDR catch-alls. User rules can
therefore override the catch-alls (e.g. reject the postgres role from
outside the pod network) but cannot lock the operator out of the unix
socket or loopback.

The mechanism is version-gated, verified empirically on both paths:

  * PostgreSQL >= 16 emits
        include_if_exists "/etc/config/user_hba.conf"
    so a secret update takes effect on pg_reload_conf() with no
    restart, mirroring how user.conf already behaves for GUCs. A
    missing file is tolerated at startup and reload. The path is
    double-quoted deliberately: a single-quoted token is treated as a
    literal filename (quotes included) and include_if_exists then
    skips it silently.

  * PostgreSQL <= 15 has no include support in pg_hba.conf -- a
    include line is FATAL at startup ("could not load pg_hba.conf").
    The file content is copied in at generation time instead, so
    changes take effect on pod restart.

Only long-running server paths are changed: primary/start.sh,
standby/run.sh and standby/remote-replica.sh. warm_stanby.sh,
ha_backup_job.sh and restore.sh also generate pg_hba.conf but run
postgres only for seconds during bootstrap/restore with local access;
injecting user rules there risks breaking recovery for no security
gain.

Verified with a harness that executes the generation section of every
modified script and its master counterpart under all five
SSL/CLIENT_AUTH_MODE combinations, with and without the user file
(260 checks): without the file, <= 15 output is byte-identical to
master and >= 16 differs by exactly the include line; with the file,
the rules appear exactly once, after the essentials and before the
first catch-all, in every branch.

Note for users: rules targeting the postgres role over the pod network
can break HA -- pg-coordinator connects to peer pods as postgres with
replication=database, which matches ordinary host rules, not the
replication keyword. Restricting postgres to the pod CIDR before a
wider reject is the safe pattern; the docs guide carries the recipe.

Signed-off-by: Tamal Saha <tamal@appscode.com>
@souravbiswassanto

Copy link
Copy Markdown
Member

No changes needed here — this PR is complete as it stands, and I verified it end to end while
finishing the operator side (kubedb/postgres#926, kubedb/apimachinery#1869).

Worth recording what the split in this PR actually buys, because the operator side now depends on
it:

  • majors 9–15 cat /etc/config/user_hba.conf into /tmp/pg_hba.conf (23 scripts)
  • majors 16–18 emit include_if_exists "/etc/config/user_hba.conf" (9 scripts)

That boundary is exactly where PostgreSQL gained include support inside pg_hba.conf, and it has a
behavioural consequence the ops path has to respect: on 16+ the include is live, so updating the
projected file and calling pg_reload_conf() is enough; on ≤15 the content was copied at pod
start, so a reload would report success while changing nothing. pkg/ops/reconfigure.go in
kubedb/postgres#926 now branches on the same major-16 boundary and forces a restart below it.

Verified on a k3s cluster with an image built from this branch (postgres-init:hba), PG 17.4, so
the include_if_exists path. pg_hba_file_rules confirms the splice point holds the security
boundary — KubeDB's local/loopback and replication rules are matched before the user block, its
0.0.0.0/0 catch-alls after it:

1-6   /var/pv/data/pg_hba.conf   local + loopback + replication      <- KubeDB
7     /etc/config/user_hba.conf  host      all 10.91.0.0 reject      <- user
8     /etc/config/user_hba.conf  hostnossl all 10.92.0.0 md5         <- user
9-14  /var/pv/data/pg_hba.conf   0.0.0.0/0 and ::/0 catch-alls       <- KubeDB

Also confirmed the primary's first pg_hba.conf write — the trust-only bootstrap initdb
depends on — is untouched: the splice sites all sit after that first mv, and a DB with no
user_hba.conf at all still starts clean with the include_if_exists line present and inert
(pg-a in the test matrix).

@souravbiswassanto
souravbiswassanto merged commit d483853 into master Aug 25, 2026
5 checks passed
@souravbiswassanto
souravbiswassanto deleted the custom-pg-hba branch August 25, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants