Back to Bytebase

Instance TLS File Path Implementation Plan

docs/superpowers/plans/2026-04-20-instance-tls-file-path.md

3.17.139.6 KB
Original Source

Instance TLS File Path Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add local filesystem TLS material paths for instance data sources while keeping use_ssl as the persisted/API compatibility switch.

Architecture: Add path fields as an additive proto/API change, infer the effective TLS source from use_ssl plus path presence, and resolve file paths to PEM content before database drivers consume TLS settings. Validate and normalize API writes so mixed explicit inline/path submissions are rejected while source switches clear inactive stored material.

Tech Stack: Go, protobuf/buf, Connect RPC, Bytebase store JSONB proto metadata, React, TypeScript, Vitest.


Task 1: Proto Schema And Generated Code

Files:

  • Modify: proto/store/store/instance.proto

  • Modify: proto/v1/v1/instance_service.proto

  • Generated by cd proto && buf generate: backend/generated-go/store/instance.pb.go

  • Generated by cd proto && buf generate: backend/generated-go/store/instance_equal.pb.go

  • Generated by cd proto && buf generate: backend/generated-go/v1/instance_service.pb.go

  • Generated by cd proto && buf generate: backend/generated-go/v1/instance_service_equal.pb.go

  • Generated by cd proto && buf generate: frontend/src/types/proto-es/store/instance_pb.ts

  • Generated by cd proto && buf generate: frontend/src/types/proto-es/v1/instance_service_pb.ts

  • Generated by cd proto && buf generate: frontend/src/types/proto-es/v1/instance_service_pb.d.ts

  • Step 1: Add store path fields

In proto/store/store/instance.proto, insert these fields immediately after obfuscated_ssl_key = 7;:

proto
  string ssl_ca_path = 50;
  string obfuscated_ssl_ca_path = 51;
  string ssl_cert_path = 52;
  string obfuscated_ssl_cert_path = 53;
  string ssl_key_path = 54;
  string obfuscated_ssl_key_path = 55;
  • Step 2: Add v1 API path fields and presence flags

In proto/v1/v1/instance_service.proto, insert these fields immediately after ssl_key = 7:

proto
  // The local filesystem path to the SSL certificate authority certificate.
  string ssl_ca_path = 41 [(google.api.field_behavior) = INPUT_ONLY];
  // The local filesystem path to the SSL client certificate.
  string ssl_cert_path = 42 [(google.api.field_behavior) = INPUT_ONLY];
  // The local filesystem path to the SSL client private key.
  string ssl_key_path = 43 [(google.api.field_behavior) = INPUT_ONLY];
  // Whether an SSL certificate authority path has been configured.
  bool has_ssl_ca_path = 44 [(google.api.field_behavior) = OUTPUT_ONLY];
  // Whether an SSL client certificate path has been configured.
  bool has_ssl_cert_path = 45 [(google.api.field_behavior) = OUTPUT_ONLY];
  // Whether an SSL client private key path has been configured.
  bool has_ssl_key_path = 46 [(google.api.field_behavior) = OUTPUT_ONLY];
  • Step 3: Format and lint proto

Run:

bash
buf format -w proto
buf lint proto

Expected: both commands exit 0.

  • Step 4: Regenerate proto outputs

Run:

bash
cd proto && buf generate

Expected: command exits 0 and generated Go/TypeScript files include SslCaPath, SslCertPath, SslKeyPath, HasSslCaPath, HasSslCertPath, and HasSslKeyPath.

  • Step 5: Commit proto changes

Run:

bash
git add proto/store/store/instance.proto proto/v1/v1/instance_service.proto backend/generated-go/store/instance.pb.go backend/generated-go/store/instance_equal.pb.go backend/generated-go/v1/instance_service.pb.go backend/generated-go/v1/instance_service_equal.pb.go frontend/src/types/proto-es/store/instance_pb.ts frontend/src/types/proto-es/v1/instance_service_pb.ts frontend/src/types/proto-es/v1/instance_service_pb.d.ts
git commit -m "feat(instance): add TLS path fields"

Task 2: Store Obfuscation, API Conversion, And Audit Redaction

Files:

  • Modify: backend/store/instance.go

  • Modify: backend/api/v1/instance_service_converter.go

  • Modify: backend/api/v1/instance_service.go

  • Modify: backend/api/v1/audit.go

  • Step 1: Obfuscate path fields before persistence

In backend/store/instance.go, update obfuscateInstance() after the existing SSL key obfuscation:

go
		ds.ObfuscatedSslCaPath = common.Obfuscate(ds.GetSslCaPath(), secret)
		ds.SslCaPath = ""
		ds.ObfuscatedSslCertPath = common.Obfuscate(ds.GetSslCertPath(), secret)
		ds.SslCertPath = ""
		ds.ObfuscatedSslKeyPath = common.Obfuscate(ds.GetSslKeyPath(), secret)
		ds.SslKeyPath = ""
  • Step 2: Deobfuscate path fields on read

In backend/store/instance.go, update deobfuscateInstances() after existing SSL key deobfuscation:

go
			sslCaPath, err := common.Unobfuscate(ds.GetObfuscatedSslCaPath(), secret)
			if err != nil {
				return err
			}
			ds.SslCaPath = sslCaPath

			sslCertPath, err := common.Unobfuscate(ds.GetObfuscatedSslCertPath(), secret)
			if err != nil {
				return err
			}
			ds.SslCertPath = sslCertPath

			sslKeyPath, err := common.Unobfuscate(ds.GetObfuscatedSslKeyPath(), secret)
			if err != nil {
				return err
			}
			ds.SslKeyPath = sslKeyPath
  • Step 3: Map v1 path inputs into store data sources

In backend/api/v1/instance_service_converter.go, add these assignments to convertV1DataSource() next to the existing inline SSL assignments:

go
		SslCaPath:                          dataSource.SslCaPath,
		SslCertPath:                        dataSource.SslCertPath,
		SslKeyPath:                         dataSource.SslKeyPath,
  • Step 4: Return path presence flags only

In backend/api/v1/instance_service_converter.go, update convertDataSources() to populate presence flags and keep path strings empty:

go
			HasSslCaPath:              ds.GetSslCaPath() != "",
			HasSslCertPath:            ds.GetSslCertPath() != "",
			HasSslKeyPath:             ds.GetSslKeyPath() != "",

Keep SslCaPath, SslCertPath, and SslKeyPath unset in responses because they are input-only.

  • Step 5: Accept path fields in update masks

In backend/api/v1/instance_service.go, add these cases in UpdateDataSource() next to the existing SSL mask cases:

go
		case "ssl_ca_path":
			dataSource.SslCaPath = req.Msg.DataSource.SslCaPath
		case "ssl_cert_path":
			dataSource.SslCertPath = req.Msg.DataSource.SslCertPath
		case "ssl_key_path":
			dataSource.SslKeyPath = req.Msg.DataSource.SslKeyPath
  • Step 6: Redact path fields in audit payloads

In backend/api/v1/audit.go, update redactDataSource() after inline SSL redaction:

go
	if cloned.SslCaPath != "" {
		cloned.SslCaPath = maskedString
	}
	if cloned.SslCertPath != "" {
		cloned.SslCertPath = maskedString
	}
	if cloned.SslKeyPath != "" {
		cloned.SslKeyPath = maskedString
	}
  • Step 7: Format and test the touched packages

Run:

bash
gofmt -w backend/store/instance.go backend/api/v1/instance_service_converter.go backend/api/v1/instance_service.go backend/api/v1/audit.go
go test ./backend/store ./backend/api/v1 -run 'TestAudit|TestObfuscate|TestCommon' -count=1

Expected: gofmt changes are applied and tests exit 0. If the targeted -run expression matches no tests in a package, rerun that package without -run before committing.

  • Step 8: Commit store/API mapping

Run:

bash
git add backend/store/instance.go backend/api/v1/instance_service_converter.go backend/api/v1/instance_service.go backend/api/v1/audit.go
git commit -m "feat(instance): persist TLS path fields"

Task 3: TLS Material Resolution

Files:

  • Create: backend/plugin/db/util/ssl_test.go

  • Modify: backend/plugin/db/util/ssl.go

  • Modify: backend/component/dbfactory/dbfactory.go

  • Step 1: Write failing tests for path resolution

Create backend/plugin/db/util/ssl_test.go with these tests:

go
package util

import (
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/require"

	storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)

func TestResolveTLSMaterialReadsPathFields(t *testing.T) {
	dir := t.TempDir()
	caPath := filepath.Join(dir, "ca.pem")
	certPath := filepath.Join(dir, "cert.pem")
	keyPath := filepath.Join(dir, "key.pem")
	require.NoError(t, os.WriteFile(caPath, []byte("ca-pem"), 0600))
	require.NoError(t, os.WriteFile(certPath, []byte("cert-pem"), 0600))
	require.NoError(t, os.WriteFile(keyPath, []byte("key-pem"), 0600))

	ds := &storepb.DataSource{
		UseSsl:      true,
		SslCa:       "stale-ca",
		SslCert:     "stale-cert",
		SslKey:      "stale-key",
		SslCaPath:   caPath,
		SslCertPath: certPath,
		SslKeyPath:  keyPath,
	}

	resolved, err := ResolveTLSMaterial(ds)
	require.NoError(t, err)
	require.Equal(t, "ca-pem", resolved.GetSslCa())
	require.Equal(t, "cert-pem", resolved.GetSslCert())
	require.Equal(t, "key-pem", resolved.GetSslKey())
	require.Equal(t, "stale-ca", ds.GetSslCa())
}

func TestResolveTLSMaterialRejectsRelativePath(t *testing.T) {
	_, err := ResolveTLSMaterial(&storepb.DataSource{
		UseSsl:    true,
		SslCaPath: "relative-ca.pem",
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "CA certificate path must be absolute")
	require.NotContains(t, err.Error(), "relative-ca.pem")
}

func TestResolveTLSMaterialRedactsReadErrors(t *testing.T) {
	missingPath := filepath.Join(t.TempDir(), "missing.pem")
	_, err := ResolveTLSMaterial(&storepb.DataSource{
		UseSsl:    true,
		SslCaPath: missingPath,
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "failed to read CA certificate file")
	require.False(t, strings.Contains(err.Error(), missingPath), err.Error())
	require.NotContains(t, err.Error(), "no such file")
}
  • Step 2: Run the new tests to verify failure

Run:

bash
go test ./backend/plugin/db/util -run 'TestResolveTLSMaterial' -count=1

Expected: FAIL because ResolveTLSMaterial is undefined.

  • Step 3: Implement TLS path helpers and resolution

In backend/plugin/db/util/ssl.go, add imports:

go
	"os"
	"path/filepath"

	"google.golang.org/protobuf/proto"

Add these helpers above GetTLSConfig():

go
func HasTLSPath(ds *storepb.DataSource) bool {
	return ds.GetSslCaPath() != "" || ds.GetSslCertPath() != "" || ds.GetSslKeyPath() != ""
}

func ResolveTLSMaterial(ds *storepb.DataSource) (*storepb.DataSource, error) {
	resolved := proto.CloneOf(ds)
	if !resolved.GetUseSsl() || !HasTLSPath(resolved) {
		return resolved, nil
	}

	if resolved.GetSslCaPath() != "" {
		content, err := readTLSPathFile("CA certificate", resolved.GetSslCaPath())
		if err != nil {
			return nil, err
		}
		resolved.SslCa = content
	}
	if resolved.GetSslCertPath() != "" {
		content, err := readTLSPathFile("client certificate", resolved.GetSslCertPath())
		if err != nil {
			return nil, err
		}
		resolved.SslCert = content
	}
	if resolved.GetSslKeyPath() != "" {
		content, err := readTLSPathFile("client key", resolved.GetSslKeyPath())
		if err != nil {
			return nil, err
		}
		resolved.SslKey = content
	}
	return resolved, nil
}

func readTLSPathFile(label, path string) (string, error) {
	if !filepath.IsAbs(path) {
		return "", errors.Errorf("%s path must be absolute", label)
	}
	content, err := os.ReadFile(path)
	if err != nil {
		return "", errors.Errorf("failed to read %s file", label)
	}
	if len(content) == 0 {
		return "", errors.Errorf("%s file is empty", label)
	}
	return string(content), nil
}
  • Step 4: Make GetTLSConfig resolve file paths

At the start of GetTLSConfig() in backend/plugin/db/util/ssl.go, replace direct use of the input data source:

go
func GetTLSConfig(ds *storepb.DataSource) (*tls.Config, error) {
	resolved, err := ResolveTLSMaterial(ds)
	if err != nil {
		return nil, err
	}
	ds = resolved
	if !ds.GetUseSsl() {
		return nil, nil
	}
  • Step 5: Resolve once in DBFactory for drivers that inspect raw SSL fields

In backend/component/dbfactory/dbfactory.go, add an import alias:

go
	dbutil "github.com/bytebase/bytebase/backend/plugin/db/util"

Before db.Open(...), resolve the data source:

go
	resolvedDataSource, err := dbutil.ResolveTLSMaterial(dataSource)
	if err != nil {
		return nil, err
	}

	driver, err := db.Open(
		ctx,
		instance.Metadata.GetEngine(),
		db.ConnectionConfig{
			DataSource:        resolvedDataSource,
			ConnectionContext: connectionContext,
			Password:          password,
		},
	)
  • Step 6: Run TLS resolution tests

Run:

bash
gofmt -w backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go
go test ./backend/plugin/db/util ./backend/component/dbfactory -run 'TestResolveTLSMaterial' -count=1

Expected: tests exit 0.

  • Step 7: Commit TLS resolution

Run:

bash
git add backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go
git commit -m "feat(instance): resolve TLS path material"

Task 4: API TLS Validation And Normalization

Files:

  • Create: backend/api/v1/instance_service_tls_test.go

  • Modify: backend/api/v1/instance_service.go

  • Step 1: Add focused unit tests for TLS write semantics

Create backend/api/v1/instance_service_tls_test.go with helper-level tests in package v1:

go
package v1

import (
	"testing"

	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/types/known/fieldmaskpb"

	storepb "github.com/bytebase/bytebase/backend/generated-go/store"
)

func TestValidateDataSourceTLSWriteRejectsMixedExplicitMaterial(t *testing.T) {
	err := validateDataSourceTLSWrite(
		&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
		&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
		[]string{"ssl_ca", "ssl_ca_path"},
	)
	require.Error(t, err)
	require.Contains(t, err.Error(), "cannot set both inline TLS material and TLS file paths")
}

func TestNormalizeDataSourceTLSClearsInlineWhenPathWins(t *testing.T) {
	ds := &storepb.DataSource{
		UseSsl:    true,
		SslCa:     "inline-ca",
		SslCert:   "inline-cert",
		SslKey:    "inline-key",
		SslCaPath: "/tmp/ca.pem",
	}
	normalizeDataSourceTLS(ds, []string{"ssl_ca_path"})
	require.Empty(t, ds.GetSslCa())
	require.Empty(t, ds.GetSslCert())
	require.Empty(t, ds.GetSslKey())
	require.Equal(t, "/tmp/ca.pem", ds.GetSslCaPath())
}

func TestNormalizeDataSourceTLSClearsPathsWhenSwitchingToInline(t *testing.T) {
	ds := &storepb.DataSource{
		UseSsl:      true,
		SslCa:       "inline-ca",
		SslCaPath:   "",
		SslCertPath: "",
		SslKeyPath:  "",
	}
	normalizeDataSourceTLS(ds, []string{"ssl_ca", "ssl_ca_path", "ssl_cert_path", "ssl_key_path"})
	require.Equal(t, "inline-ca", ds.GetSslCa())
	require.Empty(t, ds.GetSslCaPath())
	require.Empty(t, ds.GetSslCertPath())
	require.Empty(t, ds.GetSslKeyPath())
}

func TestNormalizeDataSourceTLSClearsAllWhenDisabled(t *testing.T) {
	ds := &storepb.DataSource{
		UseSsl:      false,
		SslCa:       "inline-ca",
		SslCert:     "inline-cert",
		SslKey:      "inline-key",
		SslCaPath:   "/tmp/ca.pem",
		SslCertPath: "/tmp/cert.pem",
		SslKeyPath:  "/tmp/key.pem",
	}
	normalizeDataSourceTLS(ds, []string{"use_ssl"})
	require.Empty(t, ds.GetSslCa())
	require.Empty(t, ds.GetSslCert())
	require.Empty(t, ds.GetSslKey())
	require.Empty(t, ds.GetSslCaPath())
	require.Empty(t, ds.GetSslCertPath())
	require.Empty(t, ds.GetSslKeyPath())
}

func TestTLSMaskPaths(t *testing.T) {
	mask := &fieldmaskpb.FieldMask{Paths: []string{"ssl_ca_path", "host"}}
	require.True(t, tlsMaskContains(mask.GetPaths(), "ssl_ca_path"))
	require.False(t, tlsMaskContains(mask.GetPaths(), "ssl_cert_path"))
}
  • Step 2: Run tests to verify failure

Run:

bash
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths' -count=1

Expected: FAIL because helper functions are undefined.

  • Step 3: Add TLS helper functions

In backend/api/v1/instance_service.go, add imports:

go
	"crypto/tls"
	"crypto/x509"
	"path/filepath"

Add these helpers near checkInstanceDataSources():

go
func hasInlineTLSMaterial(ds *storepb.DataSource) bool {
	return ds.GetSslCa() != "" || ds.GetSslCert() != "" || ds.GetSslKey() != ""
}

func hasPathTLSMaterial(ds *storepb.DataSource) bool {
	return ds.GetSslCaPath() != "" || ds.GetSslCertPath() != "" || ds.GetSslKeyPath() != ""
}

func hasExplicitInlineTLSMaterial(ds *storepb.DataSource, mask []string) bool {
	return (tlsMaskContains(mask, "ssl_ca") && ds.GetSslCa() != "") ||
		(tlsMaskContains(mask, "ssl_cert") && ds.GetSslCert() != "") ||
		(tlsMaskContains(mask, "ssl_key") && ds.GetSslKey() != "")
}

func hasExplicitPathTLSMaterial(ds *storepb.DataSource, mask []string) bool {
	return (tlsMaskContains(mask, "ssl_ca_path") && ds.GetSslCaPath() != "") ||
		(tlsMaskContains(mask, "ssl_cert_path") && ds.GetSslCertPath() != "") ||
		(tlsMaskContains(mask, "ssl_key_path") && ds.GetSslKeyPath() != "")
}

func tlsMaskContains(mask []string, field string) bool {
	for _, path := range mask {
		if path == field {
			return true
		}
	}
	return false
}

func validateDataSourceTLSWrite(requested, merged *storepb.DataSource, mask []string) error {
	if hasExplicitInlineTLSMaterial(requested, mask) && hasExplicitPathTLSMaterial(requested, mask) {
		return errors.Errorf("cannot set both inline TLS material and TLS file paths")
	}
	if hasPathTLSMaterial(merged) && hasExplicitInlineTLSMaterial(requested, mask) {
		return errors.Errorf("cannot set inline TLS material while TLS file paths are configured")
	}
	return validateDataSourceTLSConfig(merged)
}

func validateDataSourceTLSConfig(ds *storepb.DataSource) error {
	if !ds.GetUseSsl() {
		return nil
	}
	if hasPathTLSMaterial(ds) {
		for label, path := range map[string]string{
			"ssl_ca_path":   ds.GetSslCaPath(),
			"ssl_cert_path": ds.GetSslCertPath(),
			"ssl_key_path":  ds.GetSslKeyPath(),
		} {
			if path != "" && !filepath.IsAbs(path) {
				return errors.Errorf("%s must be an absolute path", label)
			}
		}
		return nil
	}
	if ds.GetSslCa() != "" {
		pool := x509.NewCertPool()
		if ok := pool.AppendCertsFromPEM([]byte(ds.GetSslCa())); !ok {
			return errors.Errorf("invalid ssl_ca PEM")
		}
	}
	if (ds.GetSslCert() == "") != (ds.GetSslKey() == "") {
		return errors.Errorf("ssl_cert and ssl_key must be both set or unset")
	}
	if ds.GetSslCert() != "" {
		if _, err := tls.X509KeyPair([]byte(ds.GetSslCert()), []byte(ds.GetSslKey())); err != nil {
			return errors.Wrap(err, "invalid ssl_cert or ssl_key PEM")
		}
	}
	return nil
}

func normalizeDataSourceTLS(ds *storepb.DataSource, _ []string) {
	if !ds.GetUseSsl() {
		clearInlineTLSMaterial(ds)
		clearPathTLSMaterial(ds)
		return
	}
	if hasPathTLSMaterial(ds) {
		clearInlineTLSMaterial(ds)
		return
	}
	clearPathTLSMaterial(ds)
}

func clearInlineTLSMaterial(ds *storepb.DataSource) {
	ds.SslCa = ""
	ds.SslCert = ""
	ds.SslKey = ""
}

func clearPathTLSMaterial(ds *storepb.DataSource) {
	ds.SslCaPath = ""
	ds.SslCertPath = ""
	ds.SslKeyPath = ""
}
  • Step 4: Validate and normalize add data source

In AddDataSource(), after checkDataSource() and before appending to metadata, add:

go
	if err := validateDataSourceTLSWrite(dataSource, dataSource, []string{
		"use_ssl",
		"ssl_ca",
		"ssl_cert",
		"ssl_key",
		"ssl_ca_path",
		"ssl_cert_path",
		"ssl_key_path",
	}); err != nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, err)
	}
	normalizeDataSourceTLS(dataSource, nil)
  • Step 5: Validate and normalize update data source

In UpdateDataSource(), after the update-mask loop and clearDataSourceAuthentication(dataSource), add:

go
	if err := validateDataSourceTLSWrite(req.Msg.DataSource, dataSource, req.Msg.UpdateMask.GetPaths()); err != nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, err)
	}
	normalizeDataSourceTLS(dataSource, req.Msg.UpdateMask.GetPaths())

Keep this before s.checkInstanceDataSources(...).

  • Step 6: Add update-mask support tests for path cases

Extend backend/api/v1/instance_service_tls_test.go with:

go
func TestValidateDataSourceTLSWriteRejectsInactiveInlineWithExistingPath(t *testing.T) {
	err := validateDataSourceTLSWrite(
		&storepb.DataSource{UseSsl: true, SslCa: "inline-ca"},
		&storepb.DataSource{UseSsl: true, SslCa: "inline-ca", SslCaPath: "/tmp/ca.pem"},
		[]string{"ssl_ca"},
	)
	require.Error(t, err)
	require.Contains(t, err.Error(), "cannot set inline TLS material while TLS file paths are configured")
}

func TestValidateDataSourceTLSConfigRejectsRelativePath(t *testing.T) {
	err := validateDataSourceTLSConfig(&storepb.DataSource{UseSsl: true, SslCaPath: "ca.pem"})
	require.Error(t, err)
	require.Contains(t, err.Error(), "ssl_ca_path must be an absolute path")
}
  • Step 7: Run API TLS tests

Run:

bash
gofmt -w backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
go test ./backend/api/v1 -run 'TestValidateDataSourceTLSWrite|TestNormalizeDataSourceTLS|TestTLSMaskPaths|TestValidateDataSourceTLSConfig' -count=1

Expected: tests exit 0.

  • Step 8: Commit API TLS validation

Run:

bash
git add backend/api/v1/instance_service.go backend/api/v1/instance_service_tls_test.go
git commit -m "fix(instance): validate TLS path writes"

Task 5: Driver And Component TLS Consumers

Files:

  • Create: backend/component/ghost/config_test.go

  • Modify: backend/component/ghost/config.go

  • Modify: backend/plugin/db/elasticsearch/elasticsearch.go

  • Modify: backend/plugin/db/elasticsearch/elasticsearch_test.go

  • Modify: backend/plugin/db/mongodb/mongodb.go

  • Modify: backend/plugin/db/mongodb/mongodb_test.go

  • Modify: backend/plugin/db/pg/pg.go

  • Modify: backend/plugin/db/cockroachdb/cockroachdb.go

  • Step 1: Add gh-ost temp-file test

Create backend/component/ghost/config_test.go:

go
package ghost

import (
	"os"
	"testing"

	"github.com/stretchr/testify/require"

	storepb "github.com/bytebase/bytebase/backend/generated-go/store"
	storepkg "github.com/bytebase/bytebase/backend/store"
)

func TestGetMigrationContextWritesTLSMaterialToFiles(t *testing.T) {
	caPEM, certPEM, keyPEM := generateTLSPEMForGhostTest(t)
	instance := &storepkg.InstanceMessage{
		Metadata: &storepb.Instance{
			Engine: storepb.Engine_MYSQL,
			DataSources: []*storepb.DataSource{{
				Type:                 storepb.DataSourceType_ADMIN,
				Host:                 "127.0.0.1",
				Port:                 "3306",
				Username:             "root",
				UseSsl:               true,
				VerifyTlsCertificate: true,
				SslCa:                caPEM,
				SslCert:              certPEM,
				SslKey:               keyPEM,
			}},
		},
	}

	ctx, err := getMigrationContext(instance, "secret", &storepkg.DatabaseMessage{DatabaseName: "db"}, "tbl", "alter table tbl add column c int", true)
	require.NoError(t, err)
	require.True(t, ctx.UseTLS)
	require.False(t, ctx.TLSAllowInsecure)
	require.FileExists(t, ctx.TLSCACertificate)
	require.FileExists(t, ctx.TLSCertificate)
	require.FileExists(t, ctx.TLSKey)

	gotCA, err := os.ReadFile(ctx.TLSCACertificate)
	require.NoError(t, err)
	require.Equal(t, caPEM, string(gotCA))
}

Add a local generateTLSPEMForGhostTest(t) helper in the same file using crypto/rsa, crypto/x509, and encoding/pem to return a self-signed CA PEM, a leaf cert PEM, and a leaf key PEM.

Use this helper implementation:

go
func generateTLSPEMForGhostTest(t *testing.T) (string, string, string) {
	t.Helper()
	caKey, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	caTemplate := &x509.Certificate{
		SerialNumber:          big.NewInt(1),
		Subject:               pkix.Name{CommonName: "test-ca"},
		NotBefore:             time.Now().Add(-time.Hour),
		NotAfter:              time.Now().Add(time.Hour),
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
		BasicConstraintsValid: true,
		IsCA:                  true,
	}
	caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
	require.NoError(t, err)

	leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	leafTemplate := &x509.Certificate{
		SerialNumber: big.NewInt(2),
		Subject:      pkix.Name{CommonName: "client"},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
	}
	leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, &leafKey.PublicKey, caKey)
	require.NoError(t, err)

	caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)})
	return string(caPEM), string(certPEM), string(keyPEM)
}

The file imports must include:

go
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"math/big"
	"time"
  • Step 2: Update gh-ost TLS setup

In backend/component/ghost/config.go, import the DB TLS utility:

go
	dbutil "github.com/bytebase/bytebase/backend/plugin/db/util"

Resolve path material before configuring gh-ost:

go
	dataSource, err = dbutil.ResolveTLSMaterial(dataSource)
	if err != nil {
		return nil, err
	}

Replace direct PEM assignment with temp-file assignment:

go
		migrationContext.UseTLS = true
		cleanup, err := configureGhostTLSFiles(migrationContext, dataSource)
		if err != nil {
			return nil, err
		}
		defer cleanup()
		migrationContext.TLSAllowInsecure = !dataSource.GetVerifyTlsCertificate()

Add helper functions in the same file:

go
func configureGhostTLSFiles(migrationContext *ghostbase.MigrationContext, dataSource *storepb.DataSource) (func(), error) {
	var files []string
	write := func(pattern, content string) (string, error) {
		if content == "" {
			return "", nil
		}
		file, err := os.CreateTemp(os.TempDir(), pattern)
		if err != nil {
			return "", err
		}
		defer file.Close()
		if _, err := file.WriteString(content); err != nil {
			return "", err
		}
		files = append(files, file.Name())
		return file.Name(), nil
	}

	var err error
	if migrationContext.TLSCACertificate, err = write("ghost-tls-ca-*", dataSource.GetSslCa()); err != nil {
		return nil, err
	}
	if migrationContext.TLSCertificate, err = write("ghost-tls-cert-*", dataSource.GetSslCert()); err != nil {
		return nil, err
	}
	if migrationContext.TLSKey, err = write("ghost-tls-key-*", dataSource.GetSslKey()); err != nil {
		return nil, err
	}
	return func() {
		for _, name := range files {
			_ = os.Remove(name)
		}
	}, nil
}

Keep cleanup deferred after SetupTLS() succeeds so gh-ost reads the files during setup.

  • Step 3: Fix Elasticsearch CA assignment

In backend/plugin/db/elasticsearch/elasticsearch.go, change the typed client CA block to use the resolved CA:

go
	if config.DataSource.GetSslCa() != "" {
		esConfig.CACert = []byte(config.DataSource.GetSslCa())
	}

Apply the same fix in both Elasticsearch and OpenSearch client setup paths if both set CACert.

  • Step 4: Fix MongoDB TLS temp-file location

In backend/plugin/db/mongodb/mongodb.go, replace os.WriteFile(caFileName, ...) with os.CreateTemp(os.TempDir(), "mongodb-tls-ca-*"), write content to that file, close it, append file.Name() to cleanup, and pass file.Name() to --tlsCAFile.

Use the same pattern for the client certificate key file with prefix mongodb-tls-client-cert-*.

  • Step 5: Use already-resolved data sources for PostgreSQL SSL mode checks

In backend/plugin/db/pg/pg.go and backend/plugin/db/cockroachdb/cockroachdb.go, keep util.GetPGSSLMode(config.DataSource) as-is after Task 3 because DBFactory passes resolved data sources to drivers. Do not add a second ResolveTLSMaterial() call in these functions.

  • Step 6: Run component and driver tests

Run:

bash
gofmt -w backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
go test ./backend/component/ghost ./backend/plugin/db/elasticsearch ./backend/plugin/db/mongodb ./backend/plugin/db/pg ./backend/plugin/db/cockroachdb -count=1

Expected: tests exit 0.

  • Step 7: Commit driver fixes

Run:

bash
git add backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
git commit -m "fix(instance): apply resolved TLS material to drivers"

Task 6: Frontend TLS Source Selection

Files:

  • Modify: frontend/src/react/components/instance/SslCertificateForm.tsx

  • Modify: frontend/src/react/components/instance/DataSourceForm.tsx

  • Modify: frontend/src/react/components/instance/common.ts

  • Modify: frontend/src/react/components/instance/InstanceFormBody.tsx

  • Modify: frontend/src/react/components/instance/InstanceFormButtons.tsx

  • Create: frontend/src/react/components/instance/tls.ts

  • Create: frontend/src/react/components/instance/SslCertificateForm.test.tsx

  • Create: frontend/src/react/components/instance/common.test.ts

  • Modify: frontend/src/locales/en-US.json

  • Modify: frontend/src/locales/es-ES.json

  • Modify: frontend/src/locales/ja-JP.json

  • Modify: frontend/src/locales/vi-VN.json

  • Modify: frontend/src/locales/zh-CN.json

  • Step 1: Add local TLS source helpers

Create frontend/src/react/components/instance/tls.ts:

ts
import type { DataSource } from "@/types/proto-es/v1/instance_service_pb";

export type TlsSource = "DISABLED" | "INLINE_PEM" | "FILE_PATH";

export const getTlsSource = (dataSource: DataSource): TlsSource => {
  if (!dataSource.useSsl) {
    return "DISABLED";
  }
  if (
    dataSource.sslCaPath ||
    dataSource.sslCertPath ||
    dataSource.sslKeyPath ||
    dataSource.hasSslCaPath ||
    dataSource.hasSslCertPath ||
    dataSource.hasSslKeyPath
  ) {
    return "FILE_PATH";
  }
  return "INLINE_PEM";
};

export const clearInlineTlsFields = (dataSource: DataSource) => {
  dataSource.sslCa = "";
  dataSource.sslCert = "";
  dataSource.sslKey = "";
};

export const clearPathTlsFields = (dataSource: DataSource) => {
  dataSource.sslCaPath = "";
  dataSource.sslCertPath = "";
  dataSource.sslKeyPath = "";
};
  • Step 2: Extend SSL certificate form props

In SslCertificateForm.tsx, add props for path mode and labels:

ts
  source?: "INLINE_PEM" | "FILE_PATH";
  onSourceChange?: (source: "INLINE_PEM" | "FILE_PATH") => void;
  caPath?: string;
  onCaPathChange?: (val: string) => void;
  certPath?: string;
  onCertPathChange?: (val: string) => void;
  sslKeyPath?: string;
  onKeyPathChange?: (val: string) => void;

Render a two-tab selector for inline PEM vs file paths when useSsl is true. In file-path mode, render <input> fields instead of droppable textareas.

  • Step 3: Add empty CA trust hint

In SslCertificateForm.tsx, render this hint under the CA input in both modes:

tsx
<p className="mt-1 text-xs text-control-light">
  {t("data-source.ssl.empty-ca-uses-system-trust")}
</p>
  • Step 4: Collapse duplicate key/cert visibility condition

In SslCertificateForm.tsx, replace the duplicate booleans with one variable:

ts
  const hasSSLKeyAndCertFields =
    showKeyAndCert || ![Engine.MSSQL].includes(engineType);

Use hasSSLKeyAndCertFields for both key and cert tab rendering.

  • Step 5: Wire source changes in DataSourceForm

In DataSourceForm.tsx, import helpers:

ts
import {
  clearInlineTlsFields,
  clearPathTlsFields,
  getTlsSource,
  type TlsSource,
} from "./tls";

Add handler near handleUseSslChanged:

ts
  const handleTlsSourceChanged = (source: TlsSource) => {
    const next = { ...dataSource, updateSsl: true };
    if (source === "DISABLED") {
      next.useSsl = false;
      clearInlineTlsFields(next);
      clearPathTlsFields(next);
    } else if (source === "INLINE_PEM") {
      next.useSsl = true;
      clearPathTlsFields(next);
    } else {
      next.useSsl = true;
      clearInlineTlsFields(next);
    }
    update(next);
  };

Pass source={getTlsSource(dataSource) === "FILE_PATH" ? "FILE_PATH" : "INLINE_PEM"} and path field props to SslCertificateForm.

  • Step 6: Include path fields in update masks

In frontend/src/react/components/instance/common.ts, update calcDataSourceUpdateMask():

ts
  if (updateSsl) {
    updateMask.add("use_ssl");
    updateMask.add("ssl_ca");
    updateMask.add("ssl_key");
    updateMask.add("ssl_cert");
    updateMask.add("ssl_ca_path");
    updateMask.add("ssl_key_path");
    updateMask.add("ssl_cert_path");
  }
  • Step 7: Include path fields in SSL presence checks

In InstanceFormBody.tsx and InstanceFormButtons.tsx, expand hasSslConfig:

ts
    const hasSslConfig = !!(
      ds.useSsl ||
      ds.sslCa ||
      ds.sslCert ||
      ds.sslKey ||
      ds.sslCaPath ||
      ds.sslCertPath ||
      ds.sslKeyPath ||
      ds.hasSslCaPath ||
      ds.hasSslCertPath ||
      ds.hasSslKeyPath
    );
  • Step 8: Add locale strings

Add these keys under data-source.ssl in every listed locale file:

json
      "inline-pem": "Inline PEM",
      "file-path": "File paths",
      "ca-cert-path": "CA certificate path",
      "client-cert-path": "Client certificate path",
      "client-key-path": "Client key path",
      "empty-ca-uses-system-trust": "Leave CA empty to use the system trust store."

Use direct translations for non-English files. If translation quality is uncertain, use English rather than leaving empty strings.

  • Step 9: Add frontend unit tests

Create frontend/src/react/components/instance/common.test.ts with:

ts
import { describe, expect, it } from "vitest";
import { DataSource } from "@/types/proto-es/v1/instance_service_pb";
import { calcDataSourceUpdateMask, type EditDataSource } from "./common";

describe("calcDataSourceUpdateMask", () => {
  it("includes TLS path fields when SSL is updated", () => {
    const editing = Object.assign(
      new DataSource({ id: "admin", useSsl: true, sslCaPath: "/tmp/ca.pem" }),
      {
        pendingCreate: false,
        updatedPassword: "",
        updatedMasterPassword: "",
        updateSsl: true,
      }
    ) as EditDataSource;
    const original = new DataSource({ id: "admin", useSsl: false });
    const mask = calcDataSourceUpdateMask(editing, original, editing);
    expect(mask).toContain("ssl_ca_path");
    expect(mask).toContain("ssl_cert_path");
    expect(mask).toContain("ssl_key_path");
  });
});

Create frontend/src/react/components/instance/SslCertificateForm.test.tsx with a render test that asserts the empty CA hint is visible:

tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SslCertificateForm } from "./SslCertificateForm";

vi.mock("react-i18next", () => ({
  useTranslation: () => ({
    t: (key: string) =>
      key === "data-source.ssl.empty-ca-uses-system-trust"
        ? "Leave CA empty to use the system trust store."
        : key,
  }),
}));

describe("SslCertificateForm", () => {
  it("shows the system trust hint", () => {
    render(<SslCertificateForm source="INLINE_PEM" />);
    expect(
      screen.getByText("Leave CA empty to use the system trust store.")
    ).toBeInTheDocument();
  });
});
  • Step 10: Run frontend checks

Run:

bash
pnpm --dir frontend fix
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx
pnpm --dir frontend type-check

Expected: commands exit 0.

  • Step 11: Commit frontend changes

Run:

bash
git add frontend/src/react/components/instance/SslCertificateForm.tsx frontend/src/react/components/instance/DataSourceForm.tsx frontend/src/react/components/instance/common.ts frontend/src/react/components/instance/InstanceFormBody.tsx frontend/src/react/components/instance/InstanceFormButtons.tsx frontend/src/react/components/instance/tls.ts frontend/src/react/components/instance/SslCertificateForm.test.tsx frontend/src/react/components/instance/common.test.ts frontend/src/locales/en-US.json frontend/src/locales/es-ES.json frontend/src/locales/ja-JP.json frontend/src/locales/vi-VN.json frontend/src/locales/zh-CN.json
git commit -m "feat(frontend): add TLS path source selection"

Task 7: Final Verification And Cleanup

Files:

  • Review all files changed by Tasks 1-6.

  • Step 1: Confirm no TLS mode enum exists

Run:

bash
rg -n "TLSMode|tls_mode|TlsMode" proto backend frontend/src/react/components/instance frontend/src/types/proto-es

Expected: no matches related to instance data source TLS source selection.

  • Step 2: Confirm no data migration was added

Run:

bash
git diff --name-only origin/main...HEAD | rg 'backend/migrator/migration|LATEST.sql|migrator_test.go'

Expected: no output.

  • Step 3: Run proto checks

Run:

bash
buf format -w proto
buf lint proto
cd proto && buf generate

Expected: commands exit 0 and git diff --exit-code after generation shows no unexpected generated drift beyond committed generated files.

  • Step 4: Run Go formatting, lint, and tests

Run:

bash
gofmt -w backend/store/instance.go backend/api/v1/instance_service.go backend/api/v1/instance_service_converter.go backend/api/v1/audit.go backend/plugin/db/util/ssl.go backend/plugin/db/util/ssl_test.go backend/component/dbfactory/dbfactory.go backend/component/ghost/config.go backend/component/ghost/config_test.go backend/plugin/db/elasticsearch/elasticsearch.go backend/plugin/db/elasticsearch/elasticsearch_test.go backend/plugin/db/mongodb/mongodb.go backend/plugin/db/mongodb/mongodb_test.go backend/plugin/db/pg/pg.go backend/plugin/db/cockroachdb/cockroachdb.go
golangci-lint run --allow-parallel-runners
go test ./backend/store ./backend/api/v1 ./backend/plugin/db/util ./backend/component/dbfactory ./backend/component/ghost ./backend/plugin/db/elasticsearch ./backend/plugin/db/mongodb ./backend/plugin/db/pg ./backend/plugin/db/cockroachdb -count=1

Expected: commands exit 0. If golangci-lint reports fixable issues, run golangci-lint run --fix --allow-parallel-runners, then rerun golangci-lint run --allow-parallel-runners.

  • Step 5: Run frontend checks

Run:

bash
pnpm --dir frontend check
pnpm --dir frontend type-check
pnpm --dir frontend exec vitest run src/react/components/instance/common.test.ts src/react/components/instance/SslCertificateForm.test.tsx

Expected: commands exit 0.

  • Step 6: Build backend

Run:

bash
go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go

Expected: command exits 0.

  • Step 7: Review final diff for compatibility

Run:

bash
git diff --stat origin/main...HEAD
git diff origin/main...HEAD -- proto/store/store/instance.proto proto/v1/v1/instance_service.proto backend/api/v1/instance_service.go backend/plugin/db/util/ssl.go frontend/src/react/components/instance

Confirm:

  • use_ssl remains in store and v1 API.

  • Only additive proto fields were introduced.

  • No persisted/API TLSMode enum exists.

  • No migration file exists.

  • API write validation rejects explicit mixed inline/path material.

  • Runtime file path reads return redacted errors.

  • Empty CA hint is present in UI strings and form rendering.

  • Step 8: Commit verification-only fixes if needed

If verification commands changed formatting or generated files, commit them:

bash
git add .
git commit -m "chore: finalize TLS path verification fixes"

If verification produced no changes, do not create an empty commit.