From 3ccc1d34f4cae00c259804b2af1005a632d50f38 Mon Sep 17 00:00:00 2001 From: moizpgedge Date: Tue, 9 Jun 2026 17:29:33 +0500 Subject: [PATCH 1/2] docs: document pg_hba_conf/pg_ident_conf + e2e Document the user-managed `pg_hba_conf` and `pg_ident_conf` fields, and add an end-to-end test that confirms the entries reach the running Postgres. - Add an "Authentication Rules" subsection under "Customizing Database Configuration" in the create-a-database guide, covering both fields, the per-node prepend behavior, the `password_encryption` interaction, and the Swarm source-IP nuance. - Add a single-node e2e test that creates a database with database- and node-level entries and asserts, via Postgres' own pg_hba_file_rules and pg_ident_file_mappings views, that they loaded without error and in the right order, then updates an entry and confirms it applies via a reload (pg_postmaster_start_time unchanged), not a restart. The test stays intentionally small: single-node for speed, no connection matrix, and no replication re-assertion, since those are covered elsewhere. PLAT-629 --- docs/using/create-db.md | 67 ++++++++++++++ e2e/pg_hba_test.go | 188 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 e2e/pg_hba_test.go diff --git a/docs/using/create-db.md b/docs/using/create-db.md index 9031b8fd..d1216df2 100644 --- a/docs/using/create-db.md +++ b/docs/using/create-db.md @@ -127,6 +127,73 @@ This example request alters the `max_connections` value for all nodes and overri Refer to the [API Reference](../api/reference.md) for details on all available settings. +### Authentication Rules + +Use the `pg_hba_conf` and `pg_ident_conf` fields to add your own client authentication rules. Each is an array of strings, one `pg_hba.conf` or `pg_ident.conf` line per element. Your entries sit between the Control Plane's rules for its own users and its catch-all, so they cannot affect replication, Patroni, or health checks. + +Entries in the `spec` object apply to all nodes. Entries set on a node in `spec.nodes[]` are prepended to the database-level entries on that node, so node rules are matched first (`pg_hba.conf` is an ordered list where the first match wins). + +The following example lets `myapp_user` connect with SCRAM from one network, maps a client certificate with common name `alice` to the `app_user` role, and adds an internal-network rule just for `n1`: + +=== "curl" + + ```sh + curl -X POST http://host-3:3000/v1/databases \ + -H 'Content-Type:application/json' \ + --data '{ + "id": "example", + "spec": { + "database_name": "example", + "database_users": [ + { + "username": "admin", + "password": "password", + "db_owner": true, + "attributes": ["LOGIN", "SUPERUSER"] + }, + { + "username": "myapp_user", + "password": "password", + "attributes": ["LOGIN"] + }, + { + "username": "app_user", + "password": "password", + "attributes": ["LOGIN"] + } + ], + "port": 5432, + "pg_hba_conf": [ + "hostssl all myapp_user 203.0.113.0/24 scram-sha-256", + "hostssl all app_user 0.0.0.0/0 cert clientcert=verify-full map=ssl_users" + ], + "pg_ident_conf": [ + "ssl_users alice app_user" + ], + "nodes": [ + { + "name": "n1", + "host_ids": ["host-1"], + "pg_hba_conf": [ + "host example myapp_user 10.0.0.0/8 scram-sha-256" + ] + }, + { "name": "n2", "host_ids": ["host-2"] } + ] + } + }' + ``` + +Updating these fields applies with a Postgres reload, not a restart. The application catch-all rule uses your `password_encryption` setting from `postgresql_conf` (default `md5`). + +!!! note + + A `pg_ident_conf` mapping has no effect on its own; it applies only when a `pg_hba_conf` rule references it with `map=`, as the `app_user` cert rule does above. + +!!! note + + On the Swarm orchestrator, write IP rules against the client's real address. The bridge gateway address is only the source for connections that originate on the host running the instance. + ## Extension Support The Control Plane supports all extensions included in the standard flavor of the [pgEdge Enterprise Postgres Image](https://github.com/pgedge/postgres-images?tab=readme-ov-file#standard-images). You can configure extension-related settings using the `postgresql_conf` object in your database specification. diff --git a/e2e/pg_hba_test.go b/e2e/pg_hba_test.go new file mode 100644 index 00000000..313e87ad --- /dev/null +++ b/e2e/pg_hba_test.go @@ -0,0 +1,188 @@ +//go:build e2e_test + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" + + api "github.com/pgEdge/control-plane/api/apiv1/gen/control_plane" +) + +// TestPgHbaPgIdentUserConfig checks that user-supplied pg_hba_conf and +// pg_ident_conf entries reach the running Postgres. It uses a single-node +// database (the cheapest topology for this check) and asks Postgres itself, +// via pg_hba_file_rules and pg_ident_file_mappings, whether it loaded the +// entries without error. It then updates an entry and confirms the change is +// applied with a reload rather than a restart. +// +// The exact position of the user zone within pg_hba.conf is covered by the +// generator golden tests; connection allow/deny behavior and replication are +// covered elsewhere, so this test stays intentionally small. +func TestPgHbaPgIdentUserConfig(t *testing.T) { + t.Parallel() + + host1 := fixture.HostIDs()[0] + + username := "admin" + password := "password" + + ctx := t.Context() + + tLog(t, "creating a single-node database with user pg_hba and pg_ident entries") + db := fixture.NewDatabaseFixture(ctx, t, &api.CreateDatabaseRequest{ + Spec: &api.DatabaseSpec{ + DatabaseName: "test_pg_hba", + DatabaseUsers: []*api.DatabaseUserSpec{ + { + Username: username, + Password: pointerTo(password), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + { + Username: "myapp_user", + Password: pointerTo(password), + Attributes: []string{"LOGIN"}, + }, + }, + Port: pointerTo(0), + PatroniPort: pointerTo(0), + // A database-level rule that applies to every node, plus an ident + // mapping. + PgHbaConf: []string{ + "host all myapp_user 203.0.113.0/24 scram-sha-256", + }, + PgIdentConf: []string{ + "ssl_users cert_admin myapp_user", + }, + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{api.Identifier(host1)}, + // A node-level rule, which is prepended ahead of the + // database-level rule. + PgHbaConf: []string{ + "host all myapp_user 10.0.0.0/8 scram-sha-256", + }, + }, + }, + }, + }) + + opts := ConnectionOptions{Username: username, Password: password} + + // postmasterStartTime is captured before the update so we can confirm the + // update reloads rather than restarts Postgres. + var postmasterStartTime time.Time + + tLog(t, "verifying Postgres loaded the user entries") + db.WithConnection(ctx, opts, t, func(conn *pgx.Conn) { + // Every rule in the file parsed without error. + require.Zero(t, hbaRuleErrors(ctx, t, conn), + "pg_hba.conf contains rules that failed to parse") + require.Zero(t, identMappingErrors(ctx, t, conn), + "pg_ident.conf contains mappings that failed to parse") + + // The user entries are present, with the node-level rule ahead of the + // database-level rule (the prepend ordering). + require.Equal(t, []string{"10.0.0.0", "203.0.113.0"}, + userRuleAddresses(ctx, t, conn), + "node-level entry should be prepended ahead of the database-level entry") + + // The pg_ident mapping is loaded. + var pgUsername string + require.NoError(t, conn.QueryRow(ctx, + "SELECT pg_username FROM pg_ident_file_mappings WHERE map_name = 'ssl_users'").Scan(&pgUsername)) + require.Equal(t, "myapp_user", pgUsername) + + require.NoError(t, conn.QueryRow(ctx, + "SELECT pg_postmaster_start_time()").Scan(&postmasterStartTime)) + }) + + tLog(t, "updating the entries and confirming a reload, not a restart") + require.NoError(t, db.Update(ctx, UpdateOptions{ + Spec: &api.DatabaseSpec{ + DatabaseName: "test_pg_hba", + DatabaseUsers: []*api.DatabaseUserSpec{ + { + Username: username, + Password: pointerTo(password), + DbOwner: pointerTo(true), + Attributes: []string{"LOGIN", "SUPERUSER"}, + }, + { + Username: "myapp_user", + Password: pointerTo(password), + Attributes: []string{"LOGIN"}, + }, + }, + Port: pointerTo(0), + PatroniPort: pointerTo(0), + PgHbaConf: []string{ + "host all myapp_user 198.51.100.0/24 scram-sha-256", + }, + PgIdentConf: []string{ + "ssl_users cert_admin myapp_user", + }, + Nodes: []*api.DatabaseNodeSpec{ + { + Name: "n1", + HostIds: []api.Identifier{api.Identifier(host1)}, + PgHbaConf: []string{ + "host all myapp_user 172.16.0.0/12 scram-sha-256", + }, + }, + }, + }, + })) + + db.WithConnection(ctx, opts, t, func(conn *pgx.Conn) { + // The updated entries are now loaded. + require.Equal(t, []string{"172.16.0.0", "198.51.100.0"}, + userRuleAddresses(ctx, t, conn)) + + // Postgres reloaded (SIGHUP) rather than restarting, so the postmaster + // start time is unchanged. + var after time.Time + require.NoError(t, conn.QueryRow(ctx, + "SELECT pg_postmaster_start_time()").Scan(&after)) + require.True(t, postmasterStartTime.Equal(after), + "Postgres should reload, not restart (start time changed)") + }) +} + +// userRuleAddresses returns the addresses of the active pg_hba rules for +// myapp_user, ordered by their position in the file. +func userRuleAddresses(ctx context.Context, t testing.TB, conn *pgx.Conn) []string { + t.Helper() + rows, err := conn.Query(ctx, ` + SELECT address + FROM pg_hba_file_rules + WHERE 'myapp_user' = ANY(user_name) + ORDER BY rule_number`) + require.NoError(t, err) + addresses, err := pgx.CollectRows(rows, pgx.RowTo[string]) + require.NoError(t, err) + return addresses +} + +func hbaRuleErrors(ctx context.Context, t testing.TB, conn *pgx.Conn) int { + t.Helper() + var count int + require.NoError(t, conn.QueryRow(ctx, + "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL").Scan(&count)) + return count +} + +func identMappingErrors(ctx context.Context, t testing.TB, conn *pgx.Conn) int { + t.Helper() + var count int + require.NoError(t, conn.QueryRow(ctx, + "SELECT count(*) FROM pg_ident_file_mappings WHERE error IS NOT NULL").Scan(&count)) + return count +} From cbb351e887be44bb3a04a93758ec0d48b1d488ee Mon Sep 17 00:00:00 2001 From: moizpgedge Date: Tue, 9 Jun 2026 18:26:36 +0500 Subject: [PATCH 2/2] docs: clarify md5 is the control-plane password_encryption default --- docs/using/create-db.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/using/create-db.md b/docs/using/create-db.md index d1216df2..cc21a559 100644 --- a/docs/using/create-db.md +++ b/docs/using/create-db.md @@ -184,7 +184,7 @@ The following example lets `myapp_user` connect with SCRAM from one network, map }' ``` -Updating these fields applies with a Postgres reload, not a restart. The application catch-all rule uses your `password_encryption` setting from `postgresql_conf` (default `md5`). +Updating these fields applies with a Postgres reload, not a restart. The application catch-all rule uses your `password_encryption` setting from `postgresql_conf`, which the Control Plane defaults to `md5`. !!! note