diff --git a/README.md b/README.md index a8b7216..be70dc9 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,40 @@ func main() { } ``` -### Query Operations +### TLS / SSL Connections (e.g. AWS DocumentDB) + +MongoDB-compatible services such as AWS DocumentDB can enforce in-transit +encryption. Enable TLS through the builder and, when the service uses a private +certificate authority, point it at the CA bundle (for DocumentDB this is the +global RDS CA bundle, `global-bundle.pem`): + +```go +opts := database.NewMongoOptions(). + SetHost("docdb-cluster.cluster-xxxx.eu-west-1.docdb.amazonaws.com:27017"). + SetAuthSource("admin"). + SetUsername("user"). + SetPassword("password"). + SetTimeout(30). + SetRetryWrites(false). // DocumentDB does not support retryable writes + SetTLS(true). // enforce TLS + SetTLSCAFile("/etc/ssl/global-bundle.pem"). // trust the DocumentDB CA bundle (implicitly enables TLS) + Build() + +db, err := database.New(opts) +if err != nil { + log.Fatal(err) +} +``` + +TLS builder options: + +| Method | Description | +|--------|-------------| +| `SetTLS(bool)` | Enables/disables a TLS connection to the server. | +| `SetTLSCAFile(string)` | Path to a PEM file with the trusted certificate authorities. Setting a non-empty path implicitly enables TLS. | +| `SetTLSInsecureSkipVerify(bool)` | Disables certificate/hostname verification. Insecure — local testing only. | + + #### FindOne with Fluent API diff --git a/pkg/database/mongodb.go b/pkg/database/mongodb.go index 98e82cc..0600481 100644 --- a/pkg/database/mongodb.go +++ b/pkg/database/mongodb.go @@ -2,7 +2,10 @@ package database import ( "context" + "crypto/tls" + "crypto/x509" "fmt" + "os" "time" "github.com/go-playground/validator/v10" @@ -23,6 +26,18 @@ type MongoOptions struct { AuthMechanism string ReplicaSet string RetryWrites bool + + // TLS enables a TLS/SSL connection to the server. This is required for + // MongoDB-compatible services that enforce in-transit encryption, such as + // AWS DocumentDB with TLS turned on. + TLS bool + // TLSCAFile is the path to a PEM file containing one or more certificate + // authorities to trust when making a TLS connection. For AWS DocumentDB this + // is the global RDS CA bundle (e.g. global-bundle.pem). + TLSCAFile string + // TLSInsecureSkipVerify disables certificate and hostname verification. + // This is insecure and should only be used for local testing. + TLSInsecureSkipVerify bool } // Validate validates the MongoOptions configuration @@ -99,6 +114,32 @@ func (b *MongoOptionsBuilder) SetRetryWrites(retryWrites bool) *MongoOptionsBuil return b } +// SetTLS enables or disables a TLS/SSL connection to the server. +// Enable this for MongoDB-compatible services that enforce in-transit +// encryption, such as AWS DocumentDB with TLS turned on. +func (b *MongoOptionsBuilder) SetTLS(tls bool) *MongoOptionsBuilder { + b.options.TLS = tls + return b +} + +// SetTLSCAFile sets the path to a PEM file containing one or more certificate +// authorities to trust when establishing a TLS connection. Setting this also +// implicitly enables TLS. For AWS DocumentDB use the global RDS CA bundle. +func (b *MongoOptionsBuilder) SetTLSCAFile(caFile string) *MongoOptionsBuilder { + b.options.TLSCAFile = caFile + if caFile != "" { + b.options.TLS = true + } + return b +} + +// SetTLSInsecureSkipVerify disables certificate and hostname verification. +// This is insecure and should only be used for local testing. +func (b *MongoOptionsBuilder) SetTLSInsecureSkipVerify(skip bool) *MongoOptionsBuilder { + b.options.TLSInsecureSkipVerify = skip + return b +} + // Build builds the Mongo options func (b *MongoOptionsBuilder) Build() *MongoOptions { return b.options @@ -161,6 +202,10 @@ func newMongoClientFromURI(ctx context.Context, options *MongoOptions) (Database SetRetryWrites(options.RetryWrites). SetMonitor(otelmongo.NewMonitor(otelmongo.WithCommandAttributeDisabled(false))) + if err := applyTLSConfig(opts, options); err != nil { + return nil, err + } + client, err := mongo.Connect(ctx, opts) return &MongoClient{ Client: client, @@ -202,6 +247,10 @@ func newMongoClientFromComponents(ctx context.Context, options *MongoOptions) (D clientOpts.SetServerAPIOptions(serverAPI) } + if err := applyTLSConfig(clientOpts, options); err != nil { + return nil, err + } + client, err := mongo.Connect(ctx, clientOpts) return &MongoClient{ Client: client, @@ -209,6 +258,36 @@ func newMongoClientFromComponents(ctx context.Context, options *MongoOptions) (D }, err } +// applyTLSConfig configures TLS on the provided client options when TLS is +// enabled. It optionally loads a custom CA bundle (required by managed services +// such as AWS DocumentDB) and supports skipping verification for local testing. +func applyTLSConfig(clientOpts *moptions.ClientOptions, options *MongoOptions) error { + if !options.TLS && options.TLSCAFile == "" { + return nil + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: options.TLSInsecureSkipVerify, + } + + if options.TLSCAFile != "" { + caBytes, err := os.ReadFile(options.TLSCAFile) + if err != nil { + return fmt.Errorf("failed to read TLS CA file %q: %w", options.TLSCAFile, err) + } + + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(caBytes) { + return fmt.Errorf("failed to parse any certificates from TLS CA file %q", options.TLSCAFile) + } + tlsConfig.RootCAs = caPool + } + + clientOpts.SetTLSConfig(tlsConfig) + return nil +} + // Ping pings the MongoDB server to check connectivity func (m *MongoClient) Ping(ctx context.Context) error { err := m.Client.Ping(ctx, nil) diff --git a/pkg/database/mongodb_test.go b/pkg/database/mongodb_test.go index ec60455..39ede3f 100644 --- a/pkg/database/mongodb_test.go +++ b/pkg/database/mongodb_test.go @@ -216,6 +216,49 @@ func TestMongoOptionsBuilder(t *testing.T) { } }) + t.Run("TLSSetters", func(t *testing.T) { + opts := NewMongoOptions(). + SetUri("mongodb://localhost"). + SetTimeout(5000). + SetTLS(true). + SetTLSInsecureSkipVerify(true). + Build() + + if !opts.TLS { + t.Error("expected TLS to be true") + } + if !opts.TLSInsecureSkipVerify { + t.Error("expected TLSInsecureSkipVerify to be true") + } + }) + + t.Run("TLSCAFileImplicitlyEnablesTLS", func(t *testing.T) { + opts := NewMongoOptions(). + SetUri("mongodb://localhost"). + SetTimeout(5000). + SetTLSCAFile("/etc/ssl/global-bundle.pem"). + Build() + + if opts.TLSCAFile != "/etc/ssl/global-bundle.pem" { + t.Errorf("expected TLSCAFile to be '/etc/ssl/global-bundle.pem', got '%s'", opts.TLSCAFile) + } + if !opts.TLS { + t.Error("expected TLS to be implicitly enabled when a CA file is set") + } + }) + + t.Run("EmptyTLSCAFileDoesNotEnableTLS", func(t *testing.T) { + opts := NewMongoOptions(). + SetUri("mongodb://localhost"). + SetTimeout(5000). + SetTLSCAFile(""). + Build() + + if opts.TLS { + t.Error("expected TLS to remain disabled when CA file is empty") + } + }) + t.Run("PartialBuilder", func(t *testing.T) { opts := NewMongoOptions(). SetUri("mongodb://localhost").