Skip to content

Commit c5d279f

Browse files
cbandytjmoore4
andcommitted
Add a validated field for Postgres HBA rules
These structured fields are easier and safer to use than raw HBA records. The validation rules of Kubernetes 1.29 (Beta in 1.25) allow for this kind of structure. Co-authored-by: TJ Moore <tj.moore@crunchydata.com> Issue: PGO-2263
1 parent d7f4913 commit c5d279f

File tree

13 files changed

+591
-44
lines changed

13 files changed

+591
-44
lines changed

config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,86 @@ spec:
3939
spec:
4040
description: PostgresClusterSpec defines the desired state of PostgresCluster
4141
properties:
42+
authentication:
43+
properties:
44+
rules:
45+
description: 'More info: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html'
46+
items:
47+
properties:
48+
connection:
49+
description: |-
50+
The connection transport this rule matches. Typical values are:
51+
1. "host" for network connections that may or may not be encrypted.
52+
2. "hostssl" for network connections encrypted using TLS.
53+
3. "hostgssenc" for network connections encrypted using GSSAPI.
54+
maxLength: 20
55+
minLength: 1
56+
pattern: ^[-a-z0-9]+$
57+
type: string
58+
databases:
59+
description: Which databases this rule matches. When omitted
60+
or empty, this rule matches all databases.
61+
items:
62+
maxLength: 63
63+
minLength: 1
64+
type: string
65+
maxItems: 20
66+
type: array
67+
x-kubernetes-list-type: atomic
68+
hba:
69+
description: One line of the "pg_hba.conf" file. Changes
70+
to this value will be automatically reloaded without validation.
71+
maxLength: 100
72+
minLength: 1
73+
pattern: ^[[:print:]]+$
74+
type: string
75+
x-kubernetes-validations:
76+
- message: cannot include other files
77+
rule: '!self.trim().startsWith("include")'
78+
method:
79+
description: |-
80+
The authentication method to use when a connection matches this rule.
81+
The special value "reject" refuses connections that match this rule.
82+
More info: https://www.postgresql.org/docs/current/auth-methods.html
83+
maxLength: 20
84+
minLength: 1
85+
pattern: ^[-a-z0-9]+$
86+
type: string
87+
x-kubernetes-validations:
88+
- message: the "trust" method is unsafe
89+
rule: self != "trust"
90+
options:
91+
additionalProperties:
92+
anyOf:
93+
- type: integer
94+
- type: string
95+
x-kubernetes-int-or-string: true
96+
maxProperties: 20
97+
type: object
98+
x-kubernetes-map-type: atomic
99+
users:
100+
description: Which user names this rule matches. When omitted
101+
or empty, this rule matches all users.
102+
items:
103+
maxLength: 63
104+
minLength: 1
105+
type: string
106+
maxItems: 20
107+
type: array
108+
x-kubernetes-list-type: atomic
109+
type: object
110+
x-kubernetes-map-type: atomic
111+
x-kubernetes-validations:
112+
- message: '"hba" cannot be combined with other fields'
113+
rule: 'has(self.hba) ? !has(self.connection) && !has(self.databases)
114+
&& !has(self.method) && !has(self.options) && !has(self.users)
115+
: true'
116+
- message: '"connection" and "method" are required'
117+
rule: 'has(self.hba) ? true : has(self.connection) && has(self.method)'
118+
maxItems: 10
119+
type: array
120+
x-kubernetes-list-type: atomic
121+
type: object
42122
backups:
43123
description: PostgreSQL backup configuration
44124
properties:

internal/controller/postgrescluster/postgres.go

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,40 @@ import (
4242
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
4343
)
4444

45+
// generatePostgresHBA converts one API rule into a structured HBA rule that
46+
// safely formats its values.
47+
func (*Reconciler) generatePostgresHBA(spec *v1beta1.PostgresHBARule) *postgres.HostBasedAuthentication {
48+
if spec == nil {
49+
return nil
50+
}
51+
52+
result := postgres.NewHBA()
53+
result.Origin(spec.Connection)
54+
result.Method(spec.Method)
55+
56+
if len(spec.Databases) > 0 {
57+
result.Databases(spec.Databases[0], spec.Databases[1:]...)
58+
}
59+
if len(spec.Users) > 0 {
60+
result.Users(spec.Users[0], spec.Users[1:]...)
61+
}
62+
if len(spec.Options) > 0 {
63+
opts := make(map[string]string, len(spec.Options))
64+
for k, v := range spec.Options {
65+
opts[k] = v.String()
66+
}
67+
result.Options(opts)
68+
}
69+
70+
return result
71+
}
72+
4573
// generatePostgresHBAs produces the HBA rules for cluster that incorporates,
4674
// from highest to lowest precedence:
4775
// 1. mandatory rules determined by controllers
4876
// 2. rules in cluster.spec.patroni.dynamicConfiguration
4977
// 3. default rules, when none were in cluster.spec
50-
func (*Reconciler) generatePostgresHBAs(
78+
func (r *Reconciler) generatePostgresHBAs(
5179
ctx context.Context, cluster *v1beta1.PostgresCluster,
5280
) *postgres.OrderedHBAs {
5381
builtin := postgres.NewHBAs()
@@ -58,13 +86,25 @@ func (*Reconciler) generatePostgresHBAs(
5886
// so connections are matched against them first.
5987
result := new(postgres.OrderedHBAs)
6088
result.Append(builtin.Mandatory...)
89+
mandatory := result.Length()
90+
91+
// Append any rules specified in the Authentication section.
92+
// These take precedence over any in the Patroni section.
93+
if authn := cluster.Spec.Authentication; authn != nil {
94+
for _, in := range authn.Rules {
95+
if len(in.HBA) > 0 {
96+
result.AppendUnstructured(in.HBA)
97+
} else {
98+
result.Append(r.generatePostgresHBA(&in.PostgresHBARule))
99+
}
100+
}
101+
}
61102

62103
// Append any rules specified in the Patroni section.
63-
before := result.Length()
64104
result.AppendUnstructured(patroni.PostgresHBAs(cluster.Spec.Patroni)...)
65105

66106
// When there are no specified rules, include the recommended defaults.
67-
if result.Length() == before {
107+
if result.Length() == mandatory {
68108
result.Append(builtin.Default...)
69109
}
70110

internal/controller/postgrescluster/postgres_test.go

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ import (
3434
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
3535
)
3636

37+
func TestGeneratePostgresHBA(t *testing.T) {
38+
reconciler := &Reconciler{}
39+
40+
assert.Assert(t, reconciler.generatePostgresHBA(nil) == nil,
41+
"expected nil to return nil")
42+
43+
for _, tt := range []struct {
44+
rule, expected string
45+
}{
46+
{
47+
rule: `{ connection: host, method: scram }`,
48+
expected: `"host" all all all "scram"`,
49+
},
50+
{
51+
rule: `{ connection: local, method: peer, databases: [one, two] }`,
52+
expected: `"local" "one","two" all all "peer"`,
53+
},
54+
{
55+
rule: `{ connection: local, method: peer, users: [alice, bob] }`,
56+
expected: `"local" all "alice","bob" all "peer"`,
57+
},
58+
{
59+
rule: `{ connection: hostssl, method: md5, options: { clientcert: verify-ca } }`,
60+
expected: `"hostssl" all all all "md5" "clientcert"="verify-ca"`,
61+
},
62+
} {
63+
var rule *v1beta1.PostgresHBARule
64+
require.UnmarshalInto(t, &rule, tt.rule)
65+
66+
hba := reconciler.generatePostgresHBA(rule)
67+
assert.Equal(t, hba.String(), tt.expected, "\n%#v", rule)
68+
}
69+
}
70+
3771
func TestGeneratePostgresHBAs(t *testing.T) {
3872
ctx := context.Background()
3973
reconciler := &Reconciler{}
@@ -50,12 +84,35 @@ func TestGeneratePostgresHBAs(t *testing.T) {
5084
assert.Assert(t, len(required) > 0,
5185
"expected at least one mandatory rule")
5286

87+
t.Run("Authentication", func(t *testing.T) {
88+
cluster := v1beta1.NewPostgresCluster()
89+
require.UnmarshalInto(t, &cluster.Spec.Authentication, `{
90+
rules: [
91+
{ connection: host, method: scram },
92+
{ connection: local, method: peer, users: [alice, bob] },
93+
],
94+
}`)
95+
96+
result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings()
97+
assert.Assert(t, cmp.Len(result, len(required)+2),
98+
"expected two rules from the Authentication section and no defaults")
99+
100+
// mandatory rules should be first
101+
assert.DeepEqual(t, result[:len(required)], required)
102+
103+
// specified rules should be last and in their original order
104+
assert.DeepEqual(t, result[len(required):], []string{
105+
`"host" all all all "scram"`,
106+
`"local" all "alice","bob" all "peer"`,
107+
})
108+
})
109+
53110
t.Run("Patroni", func(t *testing.T) {
54111
cluster := v1beta1.NewPostgresCluster()
55112
require.UnmarshalInto(t, &cluster.Spec.Patroni, `{
56-
dynamicConfiguration: {
57-
postgresql: { pg_hba: [ "first custom", "another" ] },
58-
},
113+
dynamicConfiguration: {
114+
postgresql: { pg_hba: [ "first custom", "another" ] },
115+
},
59116
}`)
60117

61118
result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings()
@@ -68,6 +125,36 @@ func TestGeneratePostgresHBAs(t *testing.T) {
68125
// specified rules should be last and in their original order
69126
assert.DeepEqual(t, result[len(required):], []string{`first custom`, `another`})
70127
})
128+
129+
t.Run("Precedence", func(t *testing.T) {
130+
cluster := v1beta1.NewPostgresCluster()
131+
require.UnmarshalInto(t, &cluster.Spec.Authentication, `{
132+
rules: [
133+
{ connection: host, method: scram },
134+
{ connection: local, method: peer, users: [alice, bob] },
135+
],
136+
}`)
137+
require.UnmarshalInto(t, &cluster.Spec.Patroni, `{
138+
dynamicConfiguration: {
139+
postgresql: { pg_hba: [ "another" ] },
140+
},
141+
}`)
142+
143+
result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings()
144+
assert.Assert(t, cmp.Len(result, len(required)+2+1),
145+
"expected two rules from the Authentication section"+
146+
" plus one from the Patroni section")
147+
148+
// mandatory rules should be first
149+
assert.DeepEqual(t, result[:len(required)], required)
150+
151+
// specified rules are next, no defaults
152+
assert.DeepEqual(t, result[len(required):], []string{
153+
`"host" all all all "scram"`, // Authentication
154+
`"local" all "alice","bob" all "peer"`, // Authentication
155+
`another`, // Patroni
156+
})
157+
})
71158
}
72159

73160
func TestGeneratePostgresParameters(t *testing.T) {

internal/pgbouncer/postgres.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ func postgresqlHBAs() []*postgres.HostBasedAuthentication {
225225
// - https://www.postgresql.org/docs/current/auth-password.html
226226

227227
return []*postgres.HostBasedAuthentication{
228-
postgres.NewHBA().User(PostgresqlUser).TLS().Method("scram-sha-256"),
229-
postgres.NewHBA().User(PostgresqlUser).TCP().Method("reject"),
228+
postgres.NewHBA().Users(PostgresqlUser).TLS().Method("scram-sha-256"),
229+
postgres.NewHBA().Users(PostgresqlUser).TCP().Method("reject"),
230230
}
231231
}

internal/pgbouncer/postgres_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,6 @@ COMMIT;`))
186186
func TestPostgreSQLHBAs(t *testing.T) {
187187
rules := postgresqlHBAs()
188188
assert.Equal(t, len(rules), 2)
189-
assert.Equal(t, rules[0].String(), `hostssl all "_crunchypgbouncer" all scram-sha-256`)
190-
assert.Equal(t, rules[1].String(), `host all "_crunchypgbouncer" all reject`)
189+
assert.Equal(t, rules[0].String(), `hostssl all "_crunchypgbouncer" all "scram-sha-256"`)
190+
assert.Equal(t, rules[1].String(), `host all "_crunchypgbouncer" all "reject"`)
191191
}

internal/pgmonitor/postgres.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ func PostgreSQLHBAs(ctx context.Context, inCluster *v1beta1.PostgresCluster, out
2727
if ExporterEnabled(ctx, inCluster) || feature.Enabled(ctx, feature.OpenTelemetryMetrics) {
2828
// Limit the monitoring user to local connections using SCRAM.
2929
outHBAs.Mandatory = append(outHBAs.Mandatory,
30-
postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"),
31-
postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("::1/128"),
32-
postgres.NewHBA().TCP().User(MonitoringUser).Method("reject"))
30+
postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"),
31+
postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("::1/128"),
32+
postgres.NewHBA().TCP().Users(MonitoringUser).Method("reject"))
3333
}
3434
}
3535

internal/pgmonitor/postgres_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ func TestPostgreSQLHBA(t *testing.T) {
3939
PostgreSQLHBAs(ctx, inCluster, &outHBAs)
4040

4141
assert.Equal(t, len(outHBAs.Mandatory), 3)
42-
assert.Equal(t, outHBAs.Mandatory[0].String(), `host all "ccp_monitoring" "127.0.0.0/8" scram-sha-256`)
43-
assert.Equal(t, outHBAs.Mandatory[1].String(), `host all "ccp_monitoring" "::1/128" scram-sha-256`)
44-
assert.Equal(t, outHBAs.Mandatory[2].String(), `host all "ccp_monitoring" all reject`)
42+
assert.Equal(t, outHBAs.Mandatory[0].String(), `host all "ccp_monitoring" "127.0.0.0/8" "scram-sha-256"`)
43+
assert.Equal(t, outHBAs.Mandatory[1].String(), `host all "ccp_monitoring" "::1/128" "scram-sha-256"`)
44+
assert.Equal(t, outHBAs.Mandatory[2].String(), `host all "ccp_monitoring" all "reject"`)
4545
})
4646
}
4747

0 commit comments

Comments
 (0)