Skip to content

Commit 50d32ed

Browse files
committed
e2e mTLS validation
1 parent 708c9ea commit 50d32ed

4 files changed

Lines changed: 117 additions & 1 deletion

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ PG2_USER=postgres
3535
PG2_PASSWORD=postgres
3636
PG2_DATABASE=postgres
3737

38+
# Client certificate (mutual TLS) auth test, paths relative to repo root
39+
PG_MTLS_ROOT_CA_PATH=volumes/pg-tls/server.crt
40+
PG_MTLS_CLIENT_CERT_PATH=volumes/pg-tls/client.crt
41+
PG_MTLS_CLIENT_KEY_PATH=volumes/pg-tls/client.key
42+
3843
PEERDB_CATALOG_HOST=host.docker.internal
3944
PEERDB_CATALOG_PORT=9901
4045
PEERDB_CATALOG_USER=postgres

.github/workflows/flow.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,10 @@ jobs:
321321
-c "ALTER SYSTEM SET max_wal_senders=256;"
322322
-c "ALTER SYSTEM SET max_connections=2048;" &&
323323
(cat ./nexus/catalog/migrations/V{?,??}__* | docker exec -i "${{ job.services.catalog.id }}" psql -U postgres) &&
324+
# Adds support for certificate based client authentication (mTLS) to the catalog postgres instance.
325+
docker cp volumes/pg-tls "${{ job.services.catalog.id }}:/tmp/pg-tls" &&
326+
docker exec "${{ job.services.catalog.id }}" sh -c 'install -d -o postgres -g postgres /var/lib/postgresql/tls && install -m 644 -o postgres -g postgres /tmp/pg-tls/server.crt /var/lib/postgresql/tls/server.crt && install -m 600 -o postgres -g postgres /tmp/pg-tls/server.key /var/lib/postgresql/tls/server.key && install -m 644 -o postgres -g postgres /tmp/pg-tls/client.crt /var/lib/postgresql/tls/ca.crt' &&
327+
docker exec "${{ job.services.catalog.id }}" psql -U postgres -c "ALTER SYSTEM SET ssl=on;" -c "ALTER SYSTEM SET ssl_cert_file='/var/lib/postgresql/tls/server.crt';" -c "ALTER SYSTEM SET ssl_key_file='/var/lib/postgresql/tls/server.key';" -c "ALTER SYSTEM SET ssl_ca_file='/var/lib/postgresql/tls/ca.crt';" &&
324328
docker restart "${{ job.services.catalog.id }}"
325329
env:
326330
PGPASSWORD: postgres
@@ -627,6 +631,10 @@ jobs:
627631
PG2_USER: postgres
628632
PG2_PASSWORD: postgres
629633
PG2_DATABASE: postgres
634+
# Mutual-TLS client-certificate auth test (catalog has TLS enabled above)
635+
PG_MTLS_ROOT_CA_PATH: ${{ github.workspace }}/volumes/pg-tls/server.crt
636+
PG_MTLS_CLIENT_CERT_PATH: ${{ github.workspace }}/volumes/pg-tls/client.crt
637+
PG_MTLS_CLIENT_KEY_PATH: ${{ github.workspace }}/volumes/pg-tls/client.key
630638
PEERDB_SWITCHBOARD_ENABLED: "true"
631639
PEERDB_QUEUE_FORCE_TOPIC_CREATION: "true"
632640
ELASTICSEARCH_TEST_ADDRESS: http://localhost:9200
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package connpostgres
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jackc/pgx/v5/pgtype"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/PeerDB-io/peerdb/flow/internal"
10+
)
11+
12+
// TestPostgresMutualTLSClientCertAuth validates client-certificate (mutual TLS) authentication
13+
// against a TLS-enabled Postgres whose ssl_ca_file trusts the test client certificate.
14+
func TestPostgresMutualTLSClientCertAuth(t *testing.T) {
15+
t.Parallel()
16+
17+
if _, ok := internal.GetMutualTLSPostgresConfigFromEnv(); !ok {
18+
t.Skip("mutual-TLS Postgres fixtures not configured; " +
19+
"set PG_MTLS_CLIENT_CERT_PATH, PG_MTLS_CLIENT_KEY_PATH and PG_MTLS_ROOT_CA_PATH")
20+
}
21+
22+
clientDN := func(t *testing.T, connector *PostgresConnector) pgtype.Text {
23+
t.Helper()
24+
var dn pgtype.Text
25+
require.NoError(t, connector.Conn().QueryRow(t.Context(),
26+
"SELECT client_dn FROM pg_stat_ssl WHERE pid = pg_backend_pid()").Scan(&dn))
27+
return dn
28+
}
29+
30+
t.Run("client certificate is presented and authenticated", func(t *testing.T) {
31+
t.Parallel()
32+
cfg, _ := internal.GetMutualTLSPostgresConfigFromEnv()
33+
connector, err := NewPostgresConnector(t.Context(), nil, cfg)
34+
require.NoError(t, err)
35+
defer connector.Close()
36+
37+
dn := clientDN(t, connector)
38+
require.True(t, dn.Valid, "expected the server to record a verified client certificate")
39+
require.Contains(t, dn.String, "CN=postgres")
40+
})
41+
42+
t.Run("without a client certificate no client identity is recorded", func(t *testing.T) {
43+
t.Parallel()
44+
cfg, _ := internal.GetMutualTLSPostgresConfigFromEnv()
45+
cfg.ClientTls = nil // Explicit client TLS configuration removal.
46+
connector, err := NewPostgresConnector(t.Context(), nil, cfg)
47+
require.NoError(t, err)
48+
defer connector.Close()
49+
50+
require.False(t, clientDN(t, connector).Valid, "did not expect a client certificate identity")
51+
})
52+
}

flow/internal/test_env.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package internal
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
7+
"path/filepath"
68
"strconv"
79
"strings"
810
"testing"
@@ -12,15 +14,39 @@ import (
1214
"github.com/PeerDB-io/peerdb/flow/generated/protos"
1315
)
1416

17+
// repoRoot returns the repository root (the directory containing .env.example) so test fixtures can
18+
// be referenced by paths relative to it, independent of a test's working directory.
19+
func repoRoot() (string, error) {
20+
dir, err := os.Getwd()
21+
if err != nil {
22+
return "", err
23+
}
24+
for {
25+
if _, err := os.Stat(filepath.Join(dir, ".env.example")); err == nil {
26+
return dir, nil
27+
}
28+
parent := filepath.Dir(dir)
29+
if parent == dir {
30+
return "", errors.New("could not locate repository root (.env.example)")
31+
}
32+
dir = parent
33+
}
34+
}
35+
1536
func loadRootCAFromEnv(envVar string) *string {
1637
path := os.Getenv(envVar)
1738
if path == "" {
1839
return nil
1940
}
41+
if !filepath.IsAbs(path) {
42+
if root, err := repoRoot(); err == nil {
43+
path = filepath.Join(root, path)
44+
}
45+
}
2046
// #nosec G703 -- This is test code
2147
pem, err := os.ReadFile(path)
2248
if err != nil {
23-
panic(fmt.Sprintf("%s=%q: failed to read root CA: %v", envVar, path, err))
49+
panic(fmt.Sprintf("%s=%q: failed to read PEM file: %v", envVar, path, err))
2450
}
2551
s := string(pem)
2652
return &s
@@ -46,6 +72,31 @@ func GetAncillaryPostgresConfigFromEnv() *protos.PostgresConfig {
4672
}
4773
}
4874

75+
// GetMutualTLSPostgresConfigFromEnv builds a Postgres config that authenticates with a client
76+
// certificate over TLS. Returns (nil, false) unless PG_MTLS_CLIENT_CERT_PATH,
77+
// PG_MTLS_CLIENT_KEY_PATH and PG_MTLS_ROOT_CA_PATH are all set.
78+
func GetMutualTLSPostgresConfigFromEnv() (*protos.PostgresConfig, bool) {
79+
clientCert := loadRootCAFromEnv("PG_MTLS_CLIENT_CERT_PATH")
80+
clientKey := loadRootCAFromEnv("PG_MTLS_CLIENT_KEY_PATH")
81+
rootCA := loadRootCAFromEnv("PG_MTLS_ROOT_CA_PATH")
82+
if clientCert == nil || clientKey == nil || rootCA == nil {
83+
return nil, false
84+
}
85+
return &protos.PostgresConfig{
86+
Host: GetEnvString("PG_HOST", "localhost"),
87+
Port: uint32(getEnvUint[uint16]("PG_PORT", 5432)),
88+
User: GetEnvString("PG_USER", "postgres"),
89+
Password: GetEnvString("PG_PASSWORD", "postgres"),
90+
Database: GetEnvString("PG_DATABASE", "postgres"),
91+
RequireTls: true,
92+
RootCa: rootCA,
93+
ClientTls: &protos.ClientTlsConfig{
94+
Certificate: *clientCert,
95+
PrivateKey: *clientKey,
96+
},
97+
}, true
98+
}
99+
49100
func GetSecondaryPostgresConfigFromEnv() *protos.PostgresConfig {
50101
return &protos.PostgresConfig{
51102
Host: GetEnvString("PG2_HOST", "localhost"),

0 commit comments

Comments
 (0)