From 0f5f7604b4235438ea8b20f3482746d24c95f229 Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Mon, 27 Apr 2026 13:15:49 -0600 Subject: [PATCH 1/7] Initial Kerberos implementation --- common/auth/kerberos.go | 53 ++++++ common/config/config.go | 4 + .../sqlplugin/postgresql/driver/interface.go | 4 + .../sql/sqlplugin/postgresql/driver/pgx.go | 2 + .../sql/sqlplugin/postgresql/driver/pq.go | 2 + .../sql/sqlplugin/postgresql/plugin.go | 7 + .../sqlplugin/postgresql/session/kerberos.go | 153 ++++++++++++++++++ .../postgresql/session/kerberos_test.go | 129 +++++++++++++++ .../sqlplugin/postgresql/session/session.go | 74 +++++++++ .../postgresql/session/session_test.go | 124 ++++++++++++++ go.mod | 7 + go.sum | 23 +++ 12 files changed, 582 insertions(+) create mode 100644 common/auth/kerberos.go create mode 100644 common/persistence/sql/sqlplugin/postgresql/session/kerberos.go create mode 100644 common/persistence/sql/sqlplugin/postgresql/session/kerberos_test.go diff --git a/common/auth/kerberos.go b/common/auth/kerberos.go new file mode 100644 index 00000000000..01aa300d46d --- /dev/null +++ b/common/auth/kerberos.go @@ -0,0 +1,53 @@ +package auth + +type ( + // Kerberos describes GSSAPI/Kerberos authentication configuration. + // + // At present only the PostgreSQL persistence plugin consumes this + // struct (and only when using the pgx driver, postgres12_pgx), + // since lib/pq does not implement GSSAPI. + Kerberos struct { + Enabled bool `yaml:"enabled"` + + // Username is the Kerberos principal name (e.g. "temporal"). + // Combined with Realm it forms the full principal used to + // obtain a service ticket. Required when KeytabFile is set. + Username string `yaml:"username"` + + // Realm is the Kerberos realm (e.g. "CORP.EXAMPLE.COM"). + // Required when KeytabFile is set. + Realm string `yaml:"realm"` + + // KeytabFile is a path to a keytab containing credentials for + // Username@Realm. When set, credentials are loaded from the + // keytab; otherwise a credential cache is used. + KeytabFile string `yaml:"keytabFile"` + + // CredentialCacheFile is a path to a Kerberos credential + // cache (ccache). When KeytabFile is empty, this is used to + // locate existing credentials (e.g. populated by kinit). + // If empty, the value of $KRB5CCNAME is honoured and falls + // back to the OS default (/tmp/krb5cc_). + CredentialCacheFile string `yaml:"credentialCacheFile"` + + // ConfigFile is a path to krb5.conf. Defaults to + // /etc/krb5.conf when empty. + ConfigFile string `yaml:"configFile"` + + // ServiceName is the PostgreSQL server's Kerberos service + // name (the "krbsrvname" DSN parameter). Defaults to + // "postgres" when empty, matching libpq. + ServiceName string `yaml:"serviceName"` + + // SPN is an explicit service principal name to request + // (the "krbspn" DSN parameter). When set it takes precedence + // over ServiceName + host-derived SPN. Use for cases such + // as SQL Server-style SPNs or cross-realm trusts. + SPN string `yaml:"spn"` + + // DisableFAST turns off FAST (Flexible Authentication Secure + // Tunneling) pre-authentication. Required against some + // Active Directory KDCs that reject FAST-armored AS-REQs. + DisableFAST bool `yaml:"disableFAST"` + } +) diff --git a/common/config/config.go b/common/config/config.go index 9dde68338ba..7d78f6c9a5e 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -447,6 +447,10 @@ type ( TaskScanPartitions int `yaml:"taskScanPartitions"` // TLS is the configuration for TLS connections TLS *auth.TLS `yaml:"tls"` + // Kerberos is the configuration for GSSAPI/Kerberos + // authentication. Currently only honoured by the PostgreSQL + // plugin when using the pgx driver (postgres12_pgx). + Kerberos *auth.Kerberos `yaml:"kerberos"` } // CustomDatastoreConfig is the configuration for connecting to a custom datastore that is not supported by temporal core diff --git a/common/persistence/sql/sqlplugin/postgresql/driver/interface.go b/common/persistence/sql/sqlplugin/postgresql/driver/interface.go index ec45f1d34f0..56908f1b75d 100644 --- a/common/persistence/sql/sqlplugin/postgresql/driver/interface.go +++ b/common/persistence/sql/sqlplugin/postgresql/driver/interface.go @@ -25,6 +25,10 @@ type Driver interface { IsDupEntryError(error) bool IsDupDatabaseError(error) bool IsConnNeedsRefreshError(error) bool + // SupportsGSSAPI reports whether the underlying driver can perform + // GSSAPI/Kerberos authentication. Only the pgx driver does today; + // lib/pq does not implement the protocol exchange. + SupportsGSSAPI() bool } func isConnNeedsRefreshError(code, message string) bool { diff --git a/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go b/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go index 9b9b7d23141..8b3c033b8d2 100644 --- a/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go +++ b/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go @@ -49,6 +49,8 @@ func (p *PGXDriver) IsDupDatabaseError(err error) bool { return ok && pqErr.Code == dupDatabaseCode } +func (p *PGXDriver) SupportsGSSAPI() bool { return true } + func (p *PGXDriver) IsConnNeedsRefreshError(err error) bool { pqErr, ok := err.(*pgconn.PgError) if !ok { diff --git a/common/persistence/sql/sqlplugin/postgresql/driver/pq.go b/common/persistence/sql/sqlplugin/postgresql/driver/pq.go index af5eefcc7e3..4febb7dd28c 100644 --- a/common/persistence/sql/sqlplugin/postgresql/driver/pq.go +++ b/common/persistence/sql/sqlplugin/postgresql/driver/pq.go @@ -43,6 +43,8 @@ func (p *PQDriver) IsDupDatabaseError(err error) bool { return ok && pqErr.Code == dupDatabaseCode } +func (p *PQDriver) SupportsGSSAPI() bool { return false } + func (p *PQDriver) IsConnNeedsRefreshError(err error) bool { pqErr, ok := err.(*pq.Error) if !ok { diff --git a/common/persistence/sql/sqlplugin/postgresql/plugin.go b/common/persistence/sql/sqlplugin/postgresql/plugin.go index cd68dd0a668..2198cb70d3e 100644 --- a/common/persistence/sql/sqlplugin/postgresql/plugin.go +++ b/common/persistence/sql/sqlplugin/postgresql/plugin.go @@ -58,6 +58,13 @@ func (p *plugin) CreateDB( logger log.Logger, metricsHandler metrics.Handler, ) (sqlplugin.GenericDB, error) { + if cfg.Kerberos != nil && cfg.Kerberos.Enabled && !p.driver.SupportsGSSAPI() { + return nil, fmt.Errorf( + "postgres plugin %q does not support kerberos authentication; "+ + "use plugin %q (pgx driver) instead", + PluginName, PluginNamePGX, + ) + } connect := func() (*sqlx.DB, error) { if cfg.Connect != nil { return cfg.Connect(cfg) diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go new file mode 100644 index 00000000000..a08ef12eac7 --- /dev/null +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go @@ -0,0 +1,153 @@ +package session + +import ( + "errors" + "fmt" + "os" + "os/user" + "strings" + + krb5client "github.com/jcmturner/gokrb5/v8/client" + krb5config "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/spnego" + + "go.temporal.io/server/common/auth" +) + +const defaultKrb5ConfigPath = "/etc/krb5.conf" + +// kerberosGSS is a per-connection GSSAPI provider that satisfies the +// pgconn.GSS interface. A fresh instance is returned by +// kerberosGSSFactory on every connection attempt so that SPNEGO +// state is not shared across connections. +type kerberosGSS struct { + cfg *auth.Kerberos + spnegoCli *spnego.SPNEGO +} + +// kerberosGSSFactory returns a closure that constructs a new +// kerberosGSS instance. It is registered with pgx via +// pgconn.RegisterGSSProvider. The config pointer is captured so +// changes to it will not affect already-running connections, but +// subsequent connections will see updated values. +func kerberosGSSFactory(cfg *auth.Kerberos) func() (*kerberosGSS, error) { + return func() (*kerberosGSS, error) { + if cfg == nil || !cfg.Enabled { + return nil, errors.New("kerberos provider invoked with disabled config") + } + return &kerberosGSS{cfg: cfg}, nil + } +} + +// GetInitToken builds the initial SPNEGO token for service/host. +// pgx calls this when no explicit SPN is configured. +func (g *kerberosGSS) GetInitToken(host, service string) ([]byte, error) { + if service == "" { + service = "postgres" + } + return g.GetInitTokenFromSPN(service + "/" + host) +} + +// GetInitTokenFromSPN builds the initial SPNEGO token for the given +// service principal name. +func (g *kerberosGSS) GetInitTokenFromSPN(spn string) ([]byte, error) { + cl, err := newKrb5Client(g.cfg) + if err != nil { + return nil, err + } + g.spnegoCli = spnego.SPNEGOClient(cl, spn) + if err := g.spnegoCli.AcquireCred(); err != nil { + return nil, fmt.Errorf("kerberos: acquire credential for %q: %w", spn, err) + } + token, err := g.spnegoCli.InitSecContext() + if err != nil { + return nil, fmt.Errorf("kerberos: init security context for %q: %w", spn, err) + } + buf, err := token.Marshal() + if err != nil { + return nil, fmt.Errorf("kerberos: marshal SPNEGO token: %w", err) + } + return buf, nil +} + +// Continue is called by pgx after the server's reply to the initial +// token. PostgreSQL does not require an additional client-side +// round-trip: the server's GSSContinue response carries the +// mutual-auth token (possibly empty) and is immediately followed by +// AuthenticationOk. We therefore mark the exchange complete after +// the first server reply. +func (g *kerberosGSS) Continue(_ []byte) (done bool, outToken []byte, err error) { + if g.spnegoCli == nil { + return false, nil, errors.New("kerberos: Continue called before init token") + } + return true, nil, nil +} + +func newKrb5Client(cfg *auth.Kerberos) (*krb5client.Client, error) { + krbCfg, err := loadKrb5Config(cfg.ConfigFile) + if err != nil { + return nil, err + } + settings := []func(*krb5client.Settings){} + if cfg.DisableFAST { + settings = append(settings, krb5client.DisablePAFXFAST(true)) + } + + if cfg.KeytabFile != "" { + if cfg.Username == "" || cfg.Realm == "" { + return nil, errors.New("kerberos: username and realm are required when keytabFile is set") + } + kt, err := keytab.Load(cfg.KeytabFile) + if err != nil { + return nil, fmt.Errorf("kerberos: load keytab %q: %w", cfg.KeytabFile, err) + } + cl := krb5client.NewWithKeytab(cfg.Username, cfg.Realm, kt, krbCfg, settings...) + return cl, nil + } + + ccPath, err := resolveCCachePath(cfg.CredentialCacheFile) + if err != nil { + return nil, err + } + ccache, err := credentials.LoadCCache(ccPath) + if err != nil { + return nil, fmt.Errorf("kerberos: load credential cache %q: %w", ccPath, err) + } + cl, err := krb5client.NewFromCCache(ccache, krbCfg, settings...) + if err != nil { + return nil, fmt.Errorf("kerberos: create client from ccache: %w", err) + } + return cl, nil +} + +func loadKrb5Config(path string) (*krb5config.Config, error) { + if path == "" { + path = defaultKrb5ConfigPath + } + cfg, err := krb5config.Load(path) + if err != nil { + return nil, fmt.Errorf("kerberos: load krb5 config %q: %w", path, err) + } + return cfg, nil +} + +// resolveCCachePath picks a credential cache path using the same +// precedence as libkrb5: explicit config > $KRB5CCNAME > default +// /tmp/krb5cc_. The "FILE:" prefix permitted by MIT Kerberos is +// accepted and stripped. +func resolveCCachePath(explicit string) (string, error) { + candidate := explicit + if candidate == "" { + candidate = os.Getenv("KRB5CCNAME") + } + if candidate == "" { + u, err := user.Current() + if err != nil { + return "", fmt.Errorf("kerberos: determine current user for default ccache: %w", err) + } + return "/tmp/krb5cc_" + u.Uid, nil + } + return strings.TrimPrefix(candidate, "FILE:"), nil +} diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_test.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_test.go new file mode 100644 index 00000000000..487d93978fa --- /dev/null +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_test.go @@ -0,0 +1,129 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "go.temporal.io/server/common/auth" +) + +const testKrb5Conf = `[libdefaults] +default_realm = CORP.EXAMPLE.COM +dns_lookup_kdc = false +dns_lookup_realm = false + +[realms] +CORP.EXAMPLE.COM = { + kdc = kdc.example.com:88 + admin_server = kdc.example.com:749 +} +` + +func writeTempKrb5Conf(t *testing.T) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "krb5.conf") + require.NoError(t, os.WriteFile(path, []byte(testKrb5Conf), 0o600)) + return path +} + +func TestKerberosGSSFactory_DisabledConfig_ReturnsError(t *testing.T) { + factory := kerberosGSSFactory(&auth.Kerberos{Enabled: false}) + _, err := factory() + require.ErrorContains(t, err, "disabled config") +} + +func TestKerberosGSSFactory_NilConfig_ReturnsError(t *testing.T) { + factory := kerberosGSSFactory(nil) + _, err := factory() + require.Error(t, err) +} + +func TestKerberosGSSFactory_EnabledConfig_ReturnsInstance(t *testing.T) { + factory := kerberosGSSFactory(&auth.Kerberos{Enabled: true}) + g, err := factory() + require.NoError(t, err) + require.NotNil(t, g) + require.NotNil(t, g.cfg) +} + +func TestKerberosGSS_ContinueBeforeInit_ReturnsError(t *testing.T) { + g := &kerberosGSS{cfg: &auth.Kerberos{Enabled: true}} + _, _, err := g.Continue(nil) + require.ErrorContains(t, err, "Continue called before init token") +} + +func TestLoadKrb5Config_UsesProvidedPath(t *testing.T) { + path := writeTempKrb5Conf(t) + cfg, err := loadKrb5Config(path) + require.NoError(t, err) + require.Equal(t, "CORP.EXAMPLE.COM", cfg.LibDefaults.DefaultRealm) +} + +func TestLoadKrb5Config_MissingFile_ReturnsError(t *testing.T) { + _, err := loadKrb5Config("/nonexistent/krb5.conf") + require.Error(t, err) +} + +func TestNewKrb5Client_KeytabRequiresUsernameAndRealm(t *testing.T) { + path := writeTempKrb5Conf(t) + _, err := newKrb5Client(&auth.Kerberos{ + Enabled: true, + ConfigFile: path, + KeytabFile: "/tmp/does-not-matter.keytab", + }) + require.ErrorContains(t, err, "username and realm are required") +} + +func TestNewKrb5Client_MissingKeytabFile_ReturnsError(t *testing.T) { + path := writeTempKrb5Conf(t) + _, err := newKrb5Client(&auth.Kerberos{ + Enabled: true, + ConfigFile: path, + Username: "svc", + Realm: "CORP.EXAMPLE.COM", + KeytabFile: "/nonexistent.keytab", + }) + require.ErrorContains(t, err, "load keytab") +} + +func TestNewKrb5Client_MissingCCache_ReturnsError(t *testing.T) { + path := writeTempKrb5Conf(t) + _, err := newKrb5Client(&auth.Kerberos{ + Enabled: true, + ConfigFile: path, + CredentialCacheFile: "/nonexistent.ccache", + }) + require.ErrorContains(t, err, "load credential cache") +} + +func TestResolveCCachePath_ExplicitWins(t *testing.T) { + t.Setenv("KRB5CCNAME", "/env/path") + got, err := resolveCCachePath("/explicit/path") + require.NoError(t, err) + require.Equal(t, "/explicit/path", got) +} + +func TestResolveCCachePath_FilePrefixStripped(t *testing.T) { + got, err := resolveCCachePath("FILE:/some/path") + require.NoError(t, err) + require.Equal(t, "/some/path", got) +} + +func TestResolveCCachePath_EnvVarUsedWhenExplicitEmpty(t *testing.T) { + t.Setenv("KRB5CCNAME", "/env/path") + got, err := resolveCCachePath("") + require.NoError(t, err) + require.Equal(t, "/env/path", got) +} + +func TestResolveCCachePath_DefaultWhenNothingSet(t *testing.T) { + t.Setenv("KRB5CCNAME", "") + got, err := resolveCCachePath("") + require.NoError(t, err) + require.Contains(t, got, "/tmp/krb5cc_", "should fall back to uid-based default") +} + diff --git a/common/persistence/sql/sqlplugin/postgresql/session/session.go b/common/persistence/sql/sqlplugin/postgresql/session/session.go index fdc16a3fe3e..f30d36585b5 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/session.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/session.go @@ -5,9 +5,12 @@ import ( "fmt" "net/url" "strings" + "sync" "github.com/iancoleman/strcase" + "github.com/jackc/pgx/v5/pgconn" "github.com/jmoiron/sqlx" + "go.temporal.io/server/common/auth" "go.temporal.io/server/common/config" "go.temporal.io/server/common/persistence/sql/sqlplugin/postgresql/driver" "go.temporal.io/server/common/resolver" @@ -26,6 +29,20 @@ const ( sslCA = "sslrootcert" sslKey = "sslkey" sslCert = "sslcert" + + krbSrvName = "krbsrvname" + krbSPN = "krbspn" +) + +// gssProviderOnce guards one-time registration of the GSSAPI +// provider with pgx. pgx stores the provider in a package-level var, +// so re-registering would leak configuration across SQL instances. +// The first Kerberos-enabled connection wins; subsequent connections +// with different Kerberos configs will return an error from +// registerGSSProvider below. +var ( + gssProviderOnce sync.Once + gssProviderCfg *auth.Kerberos ) type Session struct { @@ -58,6 +75,12 @@ func createConnection( var db *sqlx.DB var err error + if cfg.Kerberos != nil && cfg.Kerberos.Enabled { + if err := registerGSSProvider(cfg.Kerberos); err != nil { + return nil, err + } + } + if cfg.PasswordCommand != nil { db, err = d.CreateRefreshableConnection(func() (string, error) { return buildDSN(cfg, resolver) @@ -160,5 +183,56 @@ func buildDSNAttr(cfg *config.SQL) (url.Values, error) { parameters.Set(sslMode, sslModeNoop) } + if cfg.Kerberos != nil && cfg.Kerberos.Enabled { + if cfg.PasswordCommand != nil { + return nil, errors.New("failed to build postgresql DSN: kerberos and passwordCommand are mutually exclusive") + } + if cfg.Kerberos.ServiceName != "" { + if parameters.Get(krbSrvName) != "" && parameters.Get(krbSrvName) != cfg.Kerberos.ServiceName { + return nil, fmt.Errorf("failed to build postgresql DSN: kerberos serviceName %q conflicts with connectAttribute krbsrvname %q", + cfg.Kerberos.ServiceName, parameters.Get(krbSrvName)) + } + parameters.Set(krbSrvName, cfg.Kerberos.ServiceName) + } + if cfg.Kerberos.SPN != "" { + if parameters.Get(krbSPN) != "" && parameters.Get(krbSPN) != cfg.Kerberos.SPN { + return nil, fmt.Errorf("failed to build postgresql DSN: kerberos SPN %q conflicts with connectAttribute krbspn %q", + cfg.Kerberos.SPN, parameters.Get(krbSPN)) + } + parameters.Set(krbSPN, cfg.Kerberos.SPN) + } + } + return parameters, nil } + +// registerGSSProvider registers a pgx GSSAPI provider on first call. +// Subsequent calls succeed only when presented with an identical +// Kerberos configuration — pgx stores the provider in a package +// global, so two SQL stores with divergent Kerberos settings cannot +// both be honoured within one process. +func registerGSSProvider(cfg *auth.Kerberos) error { + var registerErr error + gssProviderOnce.Do(func() { + gssProviderCfg = cfg + factory := kerberosGSSFactory(cfg) + pgconn.RegisterGSSProvider(func() (pgconn.GSS, error) { + return factory() + }) + }) + if gssProviderCfg != nil && !kerberosConfigEqual(gssProviderCfg, cfg) { + registerErr = errors.New("kerberos provider already registered with a different configuration; " + + "all SQL datastores in a single process must share the same kerberos settings") + } + return registerErr +} + +func kerberosConfigEqual(a, b *auth.Kerberos) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/common/persistence/sql/sqlplugin/postgresql/session/session_test.go b/common/persistence/sql/sqlplugin/postgresql/session/session_test.go index 3c73970cbba..5f7836332e6 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/session_test.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/session_test.go @@ -236,3 +236,127 @@ type mockServiceResolver struct { func (m *mockServiceResolver) Resolve(addr string) []string { return []string{m.addr} } + +func TestBuildDSNAttr_KerberosDisabled_NoKerberosParams(t *testing.T) { + cfg := &config.SQL{ + Kerberos: &auth.Kerberos{ + Enabled: false, + ServiceName: "postgres", + }, + } + result, err := buildDSNAttr(cfg) + require.NoError(t, err) + require.Empty(t, result.Get(krbSrvName), "krbsrvname should not be set when kerberos is disabled") + require.Empty(t, result.Get(krbSPN)) +} + +func TestBuildDSNAttr_KerberosEnabled_ServiceName(t *testing.T) { + cfg := &config.SQL{ + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + }, + } + result, err := buildDSNAttr(cfg) + require.NoError(t, err) + require.Equal(t, "postgres", result.Get(krbSrvName)) + require.Empty(t, result.Get(krbSPN), "krbspn not set when SPN is empty") +} + +func TestBuildDSNAttr_KerberosEnabled_SPNOverride(t *testing.T) { + cfg := &config.SQL{ + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + SPN: "postgres/db.example.com@CORP.EXAMPLE.COM", + }, + } + result, err := buildDSNAttr(cfg) + require.NoError(t, err) + require.Equal(t, "postgres", result.Get(krbSrvName)) + require.Equal(t, "postgres/db.example.com@CORP.EXAMPLE.COM", result.Get(krbSPN)) +} + +func TestBuildDSNAttr_KerberosWithTLS_BothEmitted(t *testing.T) { + cfg := &config.SQL{ + TLS: &auth.TLS{ + Enabled: true, + EnableHostVerification: true, + }, + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + }, + } + result, err := buildDSNAttr(cfg) + require.NoError(t, err) + require.Equal(t, "verify-full", result.Get(sslMode), "TLS parameters still emitted alongside Kerberos") + require.Equal(t, "postgres", result.Get(krbSrvName)) +} + +func TestBuildDSNAttr_KerberosAndPasswordCommand_ReturnsError(t *testing.T) { + cfg := &config.SQL{ + PasswordCommand: &config.PasswordCommandConfig{Command: "/usr/bin/echo"}, + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + }, + } + _, err := buildDSNAttr(cfg) + require.ErrorContains(t, err, "kerberos and passwordCommand are mutually exclusive") +} + +func TestBuildDSNAttr_KerberosConflictingServiceName_ReturnsError(t *testing.T) { + cfg := &config.SQL{ + ConnectAttributes: map[string]string{ + krbSrvName: "different", + }, + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + }, + } + _, err := buildDSNAttr(cfg) + require.ErrorContains(t, err, "conflicts with connectAttribute krbsrvname") +} + +func TestBuildDSNAttr_KerberosServiceNameMatchesAttribute_NoError(t *testing.T) { + cfg := &config.SQL{ + ConnectAttributes: map[string]string{ + krbSrvName: "postgres", + }, + Kerberos: &auth.Kerberos{ + Enabled: true, + ServiceName: "postgres", + }, + } + result, err := buildDSNAttr(cfg) + require.NoError(t, err) + require.Equal(t, "postgres", result.Get(krbSrvName)) +} + +func TestBuildDSNAttr_KerberosConflictingSPN_ReturnsError(t *testing.T) { + cfg := &config.SQL{ + ConnectAttributes: map[string]string{ + krbSPN: "postgres/other@CORP", + }, + Kerberos: &auth.Kerberos{ + Enabled: true, + SPN: "postgres/db@CORP", + }, + } + _, err := buildDSNAttr(cfg) + require.ErrorContains(t, err, "conflicts with connectAttribute krbspn") +} + +func TestKerberosConfigEqual(t *testing.T) { + a := &auth.Kerberos{Enabled: true, ServiceName: "postgres", Realm: "CORP"} + b := &auth.Kerberos{Enabled: true, ServiceName: "postgres", Realm: "CORP"} + c := &auth.Kerberos{Enabled: true, ServiceName: "postgres", Realm: "OTHER"} + require.True(t, kerberosConfigEqual(a, a), "same pointer should be equal") + require.True(t, kerberosConfigEqual(a, b), "equal field values should be equal") + require.False(t, kerberosConfigEqual(a, c), "differing fields should not be equal") + require.False(t, kerberosConfigEqual(a, nil)) + require.False(t, kerberosConfigEqual(nil, b)) + require.True(t, kerberosConfigEqual(nil, nil)) +} diff --git a/go.mod b/go.mod index 20d37e7ca1b..a919ffdcaf4 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/iancoleman/strcase v0.3.0 github.com/jackc/pgx/v5 v5.9.2 + github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jmoiron/sqlx v1.4.0 github.com/jstemmer/go-junit-report/v2 v2.1.0 github.com/lib/pq v1.12.3 @@ -98,7 +99,13 @@ require ( github.com/go-openapi/swag/stringutils v0.26.0 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect go.opentelemetry.io/collector/featuregate v1.56.0 // indirect ) diff --git a/go.sum b/go.sum index a5e5982b3cf..c1afb2ad91a 100644 --- a/go.sum +++ b/go.sum @@ -239,12 +239,19 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -261,6 +268,18 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -390,6 +409,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -504,6 +524,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= @@ -531,6 +552,7 @@ golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -538,6 +560,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= From 583951c4d3a02d6f9df9b843173b83a0532c077f Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Wed, 29 Apr 2026 15:50:56 -0600 Subject: [PATCH 2/7] Nits --- common/config/config.go | 2 +- common/persistence/sql/sqlplugin/postgresql/driver/pgx.go | 2 ++ common/persistence/sql/sqlplugin/postgresql/driver/pq.go | 2 ++ .../persistence/sql/sqlplugin/postgresql/session/kerberos.go | 3 +++ common/persistence/sql/sqlplugin/postgresql/session/session.go | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/common/config/config.go b/common/config/config.go index 7d78f6c9a5e..cf78d96b60c 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -448,7 +448,7 @@ type ( // TLS is the configuration for TLS connections TLS *auth.TLS `yaml:"tls"` // Kerberos is the configuration for GSSAPI/Kerberos - // authentication. Currently only honoured by the PostgreSQL + // authentication. Currently only honored by the PostgreSQL // plugin when using the pgx driver (postgres12_pgx). Kerberos *auth.Kerberos `yaml:"kerberos"` } diff --git a/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go b/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go index 8b3c033b8d2..4a4dcc83b4c 100644 --- a/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go +++ b/common/persistence/sql/sqlplugin/postgresql/driver/pgx.go @@ -13,6 +13,8 @@ import ( type PGXDriver struct{} +var _ Driver = (*PGXDriver)(nil) + const pgxDriverName = "pgx" func (p *PGXDriver) CreateConnection(dsn string) (*sqlx.DB, error) { diff --git a/common/persistence/sql/sqlplugin/postgresql/driver/pq.go b/common/persistence/sql/sqlplugin/postgresql/driver/pq.go index 4febb7dd28c..c0e8e6d95fc 100644 --- a/common/persistence/sql/sqlplugin/postgresql/driver/pq.go +++ b/common/persistence/sql/sqlplugin/postgresql/driver/pq.go @@ -11,6 +11,8 @@ import ( type PQDriver struct{} +var _ Driver = (*PQDriver)(nil) + const pqDriverName = "postgres" func (p *PQDriver) CreateConnection(dsn string) (*sqlx.DB, error) { diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go index a08ef12eac7..a240aa6bf7d 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go @@ -7,6 +7,7 @@ import ( "os/user" "strings" + "github.com/jackc/pgx/v5/pgconn" krb5client "github.com/jcmturner/gokrb5/v8/client" krb5config "github.com/jcmturner/gokrb5/v8/config" "github.com/jcmturner/gokrb5/v8/credentials" @@ -27,6 +28,8 @@ type kerberosGSS struct { spnegoCli *spnego.SPNEGO } +var _ pgconn.GSS = (*kerberosGSS)(nil) + // kerberosGSSFactory returns a closure that constructs a new // kerberosGSS instance. It is registered with pgx via // pgconn.RegisterGSSProvider. The config pointer is captured so diff --git a/common/persistence/sql/sqlplugin/postgresql/session/session.go b/common/persistence/sql/sqlplugin/postgresql/session/session.go index f30d36585b5..314229afe26 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/session.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/session.go @@ -210,7 +210,7 @@ func buildDSNAttr(cfg *config.SQL) (url.Values, error) { // Subsequent calls succeed only when presented with an identical // Kerberos configuration — pgx stores the provider in a package // global, so two SQL stores with divergent Kerberos settings cannot -// both be honoured within one process. +// both be honored within one process. func registerGSSProvider(cfg *auth.Kerberos) error { var registerErr error gssProviderOnce.Do(func() { From 61f19fd4ed271c39daf6836f3088aafb222514e8 Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Wed, 29 Apr 2026 15:52:48 -0600 Subject: [PATCH 3/7] Spelling --- common/auth/kerberos.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/auth/kerberos.go b/common/auth/kerberos.go index 01aa300d46d..9783e45c3f5 100644 --- a/common/auth/kerberos.go +++ b/common/auth/kerberos.go @@ -26,7 +26,7 @@ type ( // CredentialCacheFile is a path to a Kerberos credential // cache (ccache). When KeytabFile is empty, this is used to // locate existing credentials (e.g. populated by kinit). - // If empty, the value of $KRB5CCNAME is honoured and falls + // If empty, the value of $KRB5CCNAME is honored and falls // back to the OS default (/tmp/krb5cc_). CredentialCacheFile string `yaml:"credentialCacheFile"` From 50d5d6b76b6e092359a5e8918c69e3747197de13 Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Thu, 30 Apr 2026 11:47:28 -0600 Subject: [PATCH 4/7] Test fixtures and E2E test --- common/auth/kerberos.go | 5 + .../sqlplugin/postgresql/session/kerberos.go | 7 +- .../postgresql/session/kerberos_e2e_test.go | 87 +++++++++++++++ .../docker-compose.kerberos.yml | 70 ++++++++++++ develop/kerberos/Dockerfile.kdc | 17 +++ develop/kerberos/README.md | 105 ++++++++++++++++++ develop/kerberos/init-kdc.sh | 39 +++++++ develop/kerberos/kdc.conf | 14 +++ develop/kerberos/krb5.conf | 18 +++ develop/kerberos/pg_hba.conf | 15 +++ 10 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go create mode 100644 develop/docker-compose/docker-compose.kerberos.yml create mode 100644 develop/kerberos/Dockerfile.kdc create mode 100644 develop/kerberos/README.md create mode 100755 develop/kerberos/init-kdc.sh create mode 100644 develop/kerberos/kdc.conf create mode 100644 develop/kerberos/krb5.conf create mode 100644 develop/kerberos/pg_hba.conf diff --git a/common/auth/kerberos.go b/common/auth/kerberos.go index 9783e45c3f5..fe45b3de90a 100644 --- a/common/auth/kerberos.go +++ b/common/auth/kerberos.go @@ -43,6 +43,11 @@ type ( // (the "krbspn" DSN parameter). When set it takes precedence // over ServiceName + host-derived SPN. Use for cases such // as SQL Server-style SPNs or cross-realm trusts. + // + // Format: "service/host". The realm is taken from + // ConfigFile's default_realm; an optional "@REALM" suffix + // is accepted and stripped for compatibility with libpq + // convention. SPN string `yaml:"spn"` // DisableFAST turns off FAST (Flexible Authentication Secure diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go index a240aa6bf7d..5fdcc2e411d 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos.go @@ -54,8 +54,13 @@ func (g *kerberosGSS) GetInitToken(host, service string) ([]byte, error) { } // GetInitTokenFromSPN builds the initial SPNEGO token for the given -// service principal name. +// service principal name. The SPN must be in "service/host" form; +// any "@REALM" suffix is stripped because gokrb5's SPNEGO client +// derives the realm from krb5.conf rather than from the SPN string. func (g *kerberosGSS) GetInitTokenFromSPN(spn string) ([]byte, error) { + if i := strings.LastIndex(spn, "@"); i >= 0 { + spn = spn[:i] + } cl, err := newKrb5Client(g.cfg) if err != nil { return nil, err diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go new file mode 100644 index 00000000000..36b3d5cbc93 --- /dev/null +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go @@ -0,0 +1,87 @@ +//go:build kerberos + +// End-to-end test for the Kerberos GSSAPI authentication path. +// Requires a running KDC and Kerberos-authenticated Postgres; see +// develop/kerberos/README.md for the docker-compose fixture. +// +// Run with: +// go test -tags kerberos -count=1 -timeout 60s \ +// ./common/persistence/sql/sqlplugin/postgresql/session/... + +package session + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.temporal.io/server/common/auth" + "go.temporal.io/server/common/config" + "go.temporal.io/server/common/persistence/sql/sqlplugin/postgresql/driver" +) + +// envOrDefault returns the value of the named env var, or fallback if empty. +func envOrDefault(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +func TestKerberosE2E_KeytabAuth(t *testing.T) { + // Defaults assume the docker-compose.kerberos.yml fixture is up + // and that keytabs landed in develop/kerberos/keytabs/. + keytab := envOrDefault("TEMPORAL_TEST_KEYTAB", "../../../../../../develop/kerberos/keytabs/temporal.keytab") + krb5Conf := envOrDefault("KRB5_CONFIG", "../../../../../../develop/kerberos/krb5.conf") + addr := envOrDefault("TEMPORAL_TEST_PG_ADDR", "127.0.0.1:5433") + realm := envOrDefault("TEMPORAL_TEST_KRB_REALM", "EXAMPLE.LOCAL") + user := envOrDefault("TEMPORAL_TEST_KRB_USER", "temporal") + + if _, err := os.Stat(keytab); err != nil { + t.Skipf("keytab not found at %q (set TEMPORAL_TEST_KEYTAB or bring up develop/docker-compose/docker-compose.kerberos.yml): %v", keytab, err) + } + if _, err := os.Stat(krb5Conf); err != nil { + t.Skipf("krb5.conf not found at %q: %v", krb5Conf, err) + } + + cfg := &config.SQL{ + PluginName: "postgres12_pgx", + DatabaseName: "temporal", + ConnectAddr: addr, + ConnectProtocol: "tcp", + User: user, + Kerberos: &auth.Kerberos{ + Enabled: true, + Username: user, + Realm: realm, + KeytabFile: keytab, + ConfigFile: krb5Conf, + ServiceName: "postgres", + // SPN must match what the Postgres keytab holds (service + // and host components only; the realm is taken from + // krb5.conf default_realm). Fixture principal is + // postgres/localhost@EXAMPLE.LOCAL. + SPN: "postgres/localhost", + }, + } + + sess, err := NewSession(cfg, &driver.PGXDriver{}, &noopResolver{}) + require.NoError(t, err, "NewSession with kerberos config should succeed against the test fixture") + defer sess.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, sess.PingContext(ctx), "ping over kerberos-authenticated connection should succeed") + + var who string + err = sess.GetContext(ctx, &who, "SELECT current_user") + require.NoError(t, err) + require.Equal(t, user, who, "current_user should match the kerberos principal (with realm stripped via include_realm=0)") +} + +type noopResolver struct{} + +func (noopResolver) Resolve(addr string) []string { return []string{addr} } diff --git a/develop/docker-compose/docker-compose.kerberos.yml b/develop/docker-compose/docker-compose.kerberos.yml new file mode 100644 index 00000000000..331beec03c9 --- /dev/null +++ b/develop/docker-compose/docker-compose.kerberos.yml @@ -0,0 +1,70 @@ +# Kerberos E2E test fixture. +# +# Brings up an MIT KDC and a Kerberos-authenticated Postgres so the +# Kerberos code paths in postgresql/session can be exercised end-to-end. +# +# Usage: +# cd develop/docker-compose +# docker compose -f docker-compose.kerberos.yml up --build -d +# +# # from the repo root, with the KDC running on host:88 and the +# # postgres on host:5433: +# go test -tags kerberos -count=1 \ +# -timeout 60s \ +# ./common/persistence/sql/sqlplugin/postgresql/session/... +# +# docker compose -f docker-compose.kerberos.yml down -v +# +# Notes: +# - The KDC binds to host port 88. On Linux this requires the +# container to start as a user with CAP_NET_BIND_SERVICE; Docker's +# default capability set covers this. On macOS / Docker Desktop +# binding privileged ports works out of the box. +# - The keytabs end up on a host-mounted volume at +# ${PWD}/../kerberos/keytabs/. The Go test reads them from that +# path via the TEMPORAL_KEYTAB / KRB5_CONFIG env vars (see README). + +services: + kdc: + build: + context: ../kerberos + dockerfile: Dockerfile.kdc + container_name: temporal-dev-krb5-kdc + environment: + REALM: EXAMPLE.LOCAL + ports: + - "88:88/tcp" + - "88:88/udp" + volumes: + - ../kerberos/keytabs:/shared + networks: + - temporal-krb-network + + postgres-krb: + image: postgres:13.5 + container_name: temporal-dev-krb5-postgres + depends_on: + kdc: + condition: service_started + environment: + POSTGRES_USER: temporal + POSTGRES_PASSWORD: temporal + POSTGRES_DB: temporal + ports: + # Bind to 5433 on the host to avoid clashing with the standard + # password-auth Postgres in docker-compose.yml. + - "5433:5432" + volumes: + - ../kerberos/keytabs:/etc/postgres-keytab:ro + - ../kerberos/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro + command: > + postgres + -c hba_file=/etc/postgresql/pg_hba.conf + -c krb_server_keyfile=/etc/postgres-keytab/postgres.keytab + -c log_connections=on + networks: + - temporal-krb-network + +networks: + temporal-krb-network: + name: temporal-krb-network diff --git a/develop/kerberos/Dockerfile.kdc b/develop/kerberos/Dockerfile.kdc new file mode 100644 index 00000000000..b45f9da39e1 --- /dev/null +++ b/develop/kerberos/Dockerfile.kdc @@ -0,0 +1,17 @@ +FROM alpine:3.19 + +RUN apk add --no-cache krb5 krb5-server bash + +ENV REALM=EXAMPLE.LOCAL +ENV KRB5_CONFIG=/etc/krb5.conf +ENV KRB5_KDC_PROFILE=/var/lib/krb5kdc/kdc.conf +ENV SHARED_DIR=/shared + +COPY krb5.conf /etc/krb5.conf +COPY kdc.conf /var/lib/krb5kdc/kdc.conf +COPY init-kdc.sh /usr/local/bin/init-kdc.sh +RUN chmod +x /usr/local/bin/init-kdc.sh + +EXPOSE 88/tcp 88/udp + +ENTRYPOINT ["/usr/local/bin/init-kdc.sh"] diff --git a/develop/kerberos/README.md b/develop/kerberos/README.md new file mode 100644 index 00000000000..8b4cdf30577 --- /dev/null +++ b/develop/kerberos/README.md @@ -0,0 +1,105 @@ +# Kerberos E2E test fixture + +This directory holds the docker-compose fixture used to exercise the +PostgreSQL Kerberos (GSSAPI) authentication path implemented under +`common/persistence/sql/sqlplugin/postgresql/session`. + +## What it does + +`docker-compose.kerberos.yml` (in the sibling `docker-compose/` dir) +brings up two containers: + +- **`temporal-dev-krb5-kdc`** — an Alpine-based MIT KDC. On first + start it creates the `EXAMPLE.LOCAL` realm, registers the + principals `postgres/localhost@EXAMPLE.LOCAL` and + `temporal@EXAMPLE.LOCAL`, and writes their keytabs into the host + directory `develop/kerberos/keytabs/`. It binds host port 88 (TCP + + UDP). +- **`temporal-dev-krb5-postgres`** — the official `postgres:13.5` + image (built `--with-gssapi`) with the postgres keytab and a + `pg_hba.conf` that requires `gss` for all TCP connections. It + binds host port 5433. + +## Bring it up + +```bash +cd develop/docker-compose +docker compose -f docker-compose.kerberos.yml up --build -d +``` + +Wait a few seconds for the KDC to seed its database, then verify the +keytabs were created: + +```bash +ls develop/kerberos/keytabs/ +# postgres.keytab temporal.keytab +``` + +## Run the E2E test + +The test is gated behind a `kerberos` build tag so it never runs in +default CI: + +```bash +go test -tags kerberos -count=1 -timeout 60s \ + ./common/persistence/sql/sqlplugin/postgresql/session/... +``` + +Override fixture paths or hostnames via env vars when running outside +the default layout: + +| Env var | Default | +| ------------------------- | -------------------------------------------------------------------- | +| `TEMPORAL_TEST_KEYTAB` | `develop/kerberos/keytabs/temporal.keytab` (relative to the test) | +| `KRB5_CONFIG` | `develop/kerberos/krb5.conf` (relative to the test) | +| `TEMPORAL_TEST_PG_ADDR` | `127.0.0.1:5433` | +| `TEMPORAL_TEST_KRB_REALM` | `EXAMPLE.LOCAL` | +| `TEMPORAL_TEST_KRB_USER` | `temporal` | + +If the keytab or krb5.conf is missing, the test skips with a clear +message instead of failing — so it's safe to run in environments +without the fixture. + +## Tear it down + +```bash +cd develop/docker-compose +docker compose -f docker-compose.kerberos.yml down -v +rm -rf ../kerberos/keytabs +``` + +The `-v` removes the bind volume content reference; the +`rm -rf ../kerberos/keytabs` clears the host-side keytabs so the next +`up` regenerates them cleanly. + +## Troubleshooting + +- **`Cannot find KDC for requested realm`** — the KDC isn't reachable + on `127.0.0.1:88`. Confirm with `docker compose ps` and + `docker logs temporal-dev-krb5-kdc`. +- **`Clock skew too great`** — Kerberos requires the test host's + clock to be within ±5 minutes of the KDC. Containers usually + inherit the host clock, but Docker Desktop on macOS occasionally + drifts after sleep. Restart Docker Desktop. +- **`Server not found in Kerberos database`** — the Postgres SPN in + the keytab doesn't match what the client requests. The fixture + uses `postgres/localhost@EXAMPLE.LOCAL`; the test config's + `kerberos.spn` field must match. +- **`permission denied for keytab`** — re-run `docker compose up`; + `init-kdc.sh` re-chmods the keytabs to 644 on each fresh init. +- **Port 88 already bound** — another KDC or a packet capture is + holding it. Stop that, or temporarily edit the compose file to map + to `8888:88` and update `krb5.conf` to match. + +## What the fixture does NOT cover + +- Cross-realm trusts (the test fixture has one realm). +- Active Directory-specific quirks like FAST disablement, RC4-HMAC + enctypes, or constrained delegation. The unit tests cover the + config wiring for these (`disableFAST`); a full AD-equivalent + fixture would need Samba 4 or a real AD lab. +- Credential cache (ccache) authentication. The fixture uses keytab + auth because it's the production-server case; ccache works + identically from the test code's perspective if you `kinit` first + and point the test at the resulting cache file via + `kerberos.credentialCacheFile`. diff --git a/develop/kerberos/init-kdc.sh b/develop/kerberos/init-kdc.sh new file mode 100755 index 00000000000..4a4dceb40cd --- /dev/null +++ b/develop/kerberos/init-kdc.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Initializes a fresh KDC database, creates the test principals, +# generates keytabs into the shared volume, and starts krb5kdc in +# the foreground. +set -euo pipefail + +REALM="${REALM:-EXAMPLE.LOCAL}" +SHARED_DIR="${SHARED_DIR:-/shared}" +MASTER_PASS="${MASTER_PASS:-masterpassword}" + +POSTGRES_KEYTAB="${SHARED_DIR}/postgres.keytab" +TEMPORAL_KEYTAB="${SHARED_DIR}/temporal.keytab" + +mkdir -p "${SHARED_DIR}" + +if [ ! -f /var/lib/krb5kdc/principal ]; then + echo "Initializing KDC database for realm ${REALM}..." + kdb5_util create -s -r "${REALM}" -P "${MASTER_PASS}" + + # Allow the admin principal to do anything (only used internally for ktadd) + echo "*/admin@${REALM} *" > /var/lib/krb5kdc/kadm5.acl + + echo "Creating principals..." + kadmin.local -q "addprinc -randkey postgres/localhost@${REALM}" + kadmin.local -q "addprinc -randkey temporal@${REALM}" + + echo "Generating keytabs into ${SHARED_DIR}..." + rm -f "${POSTGRES_KEYTAB}" "${TEMPORAL_KEYTAB}" + kadmin.local -q "ktadd -k ${POSTGRES_KEYTAB} postgres/localhost@${REALM}" + kadmin.local -q "ktadd -k ${TEMPORAL_KEYTAB} temporal@${REALM}" + + chmod 644 "${POSTGRES_KEYTAB}" "${TEMPORAL_KEYTAB}" + echo "KDC initialization complete." +else + echo "KDC database already exists, skipping initialization." +fi + +echo "Starting krb5kdc in foreground..." +exec krb5kdc -n diff --git a/develop/kerberos/kdc.conf b/develop/kerberos/kdc.conf new file mode 100644 index 00000000000..de730312f22 --- /dev/null +++ b/develop/kerberos/kdc.conf @@ -0,0 +1,14 @@ +[kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + +[realms] + EXAMPLE.LOCAL = { + database_name = /var/lib/krb5kdc/principal + admin_keytab = FILE:/var/lib/krb5kdc/kadm5.keytab + acl_file = /var/lib/krb5kdc/kadm5.acl + key_stash_file = /var/lib/krb5kdc/.k5.EXAMPLE.LOCAL + max_life = 10h 0m 0s + max_renewable_life = 7d 0h 0m 0s + supported_enctypes = aes256-cts-hmac-sha1-96:normal aes128-cts-hmac-sha1-96:normal + } diff --git a/develop/kerberos/krb5.conf b/develop/kerberos/krb5.conf new file mode 100644 index 00000000000..d85da770c70 --- /dev/null +++ b/develop/kerberos/krb5.conf @@ -0,0 +1,18 @@ +[libdefaults] + default_realm = EXAMPLE.LOCAL + dns_lookup_realm = false + dns_lookup_kdc = false + rdns = false + forwardable = true + udp_preference_limit = 1 + +[realms] + EXAMPLE.LOCAL = { + kdc = 127.0.0.1:88 + admin_server = 127.0.0.1:749 + } + +[domain_realm] + .example.local = EXAMPLE.LOCAL + example.local = EXAMPLE.LOCAL + localhost = EXAMPLE.LOCAL diff --git a/develop/kerberos/pg_hba.conf b/develop/kerberos/pg_hba.conf new file mode 100644 index 00000000000..b54bdc246f5 --- /dev/null +++ b/develop/kerberos/pg_hba.conf @@ -0,0 +1,15 @@ +# PostgreSQL Client Authentication Configuration File for Kerberos E2E tests. +# Allows GSSAPI authentication from any client; the realm is not validated +# inline because we only have one realm in the test fixture. +# +# Format: TYPE DATABASE USER ADDRESS METHOD [OPTIONS] + +# Local Unix-socket access stays trust so the postgres bootstrap and the +# image's healthcheck still work without a ticket. +local all all trust + +# All TCP connections must use GSSAPI. include_realm=0 strips the @REALM +# suffix from the principal so "temporal@EXAMPLE.LOCAL" maps to the +# Postgres role "temporal". +host all all 0.0.0.0/0 gss include_realm=0 krb_realm=EXAMPLE.LOCAL +host all all ::/0 gss include_realm=0 krb_realm=EXAMPLE.LOCAL From c3a56607672205a79f2a077518adc0ba6abe4d9c Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Thu, 30 Apr 2026 11:48:25 -0600 Subject: [PATCH 5/7] Add keytabs to gitignore --- develop/kerberos/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 develop/kerberos/.gitignore diff --git a/develop/kerberos/.gitignore b/develop/kerberos/.gitignore new file mode 100644 index 00000000000..f592bb7b6b1 --- /dev/null +++ b/develop/kerberos/.gitignore @@ -0,0 +1,4 @@ +# Keytabs are generated at runtime by init-kdc.sh into a host bind +# mount and are unique per fixture run. They contain principal keys +# and should never be committed. +keytabs/ From 7609cb5967ef32350c1a95a7f767118029d94fa4 Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Thu, 30 Apr 2026 12:14:12 -0600 Subject: [PATCH 6/7] Add ccache test and Once reset test helper --- .../postgresql/session/kerberos_e2e_test.go | 64 +++++++++++++++++++ develop/kerberos/.gitignore | 2 + develop/kerberos/README.md | 22 +++++-- develop/kerberos/init-kdc.sh | 19 ++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go index 36b3d5cbc93..aeb67810be5 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go @@ -13,6 +13,7 @@ package session import ( "context" "os" + "sync" "testing" "time" @@ -23,6 +24,19 @@ import ( "go.temporal.io/server/common/persistence/sql/sqlplugin/postgresql/driver" ) +// resetGSSProviderForTesting wipes the package-level Once and cached +// kerberos config so a subsequent NewSession call can register a +// fresh GSS provider with a different config. Production code uses +// the once-and-rejection-on-mismatch behavior to prevent two SQL +// stores from racing on different Kerberos settings; tests need to +// bypass it to exercise both the keytab and ccache code paths in one +// process. +func resetGSSProviderForTesting(t *testing.T) { + t.Helper() + gssProviderOnce = sync.Once{} + gssProviderCfg = nil +} + // envOrDefault returns the value of the named env var, or fallback if empty. func envOrDefault(name, fallback string) string { if v := os.Getenv(name); v != "" { @@ -32,6 +46,7 @@ func envOrDefault(name, fallback string) string { } func TestKerberosE2E_KeytabAuth(t *testing.T) { + resetGSSProviderForTesting(t) // Defaults assume the docker-compose.kerberos.yml fixture is up // and that keytabs landed in develop/kerberos/keytabs/. keytab := envOrDefault("TEMPORAL_TEST_KEYTAB", "../../../../../../develop/kerberos/keytabs/temporal.keytab") @@ -82,6 +97,55 @@ func TestKerberosE2E_KeytabAuth(t *testing.T) { require.Equal(t, user, who, "current_user should match the kerberos principal (with realm stripped via include_realm=0)") } +func TestKerberosE2E_CCacheAuth(t *testing.T) { + resetGSSProviderForTesting(t) + // Same fixture as the keytab test; this exercises the alternate + // credential source path in newKrb5Client (CredentialCacheFile + // instead of KeytabFile). init-kdc.sh refreshes this ccache on + // every container start so it shouldn't be stale. + ccache := envOrDefault("TEMPORAL_TEST_CCACHE", "../../../../../../develop/kerberos/keytabs/temporal.ccache") + krb5Conf := envOrDefault("KRB5_CONFIG", "../../../../../../develop/kerberos/krb5.conf") + addr := envOrDefault("TEMPORAL_TEST_PG_ADDR", "127.0.0.1:5433") + realm := envOrDefault("TEMPORAL_TEST_KRB_REALM", "EXAMPLE.LOCAL") + user := envOrDefault("TEMPORAL_TEST_KRB_USER", "temporal") + + if _, err := os.Stat(ccache); err != nil { + t.Skipf("ccache not found at %q (set TEMPORAL_TEST_CCACHE or bring up develop/docker-compose/docker-compose.kerberos.yml): %v", ccache, err) + } + if _, err := os.Stat(krb5Conf); err != nil { + t.Skipf("krb5.conf not found at %q: %v", krb5Conf, err) + } + + cfg := &config.SQL{ + PluginName: "postgres12_pgx", + DatabaseName: "temporal", + ConnectAddr: addr, + ConnectProtocol: "tcp", + User: user, + Kerberos: &auth.Kerberos{ + Enabled: true, + Realm: realm, + CredentialCacheFile: ccache, + ConfigFile: krb5Conf, + ServiceName: "postgres", + SPN: "postgres/localhost", + }, + } + + sess, err := NewSession(cfg, &driver.PGXDriver{}, &noopResolver{}) + require.NoError(t, err, "NewSession with kerberos ccache config should succeed against the test fixture") + defer sess.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, sess.PingContext(ctx)) + + var who string + err = sess.GetContext(ctx, &who, "SELECT current_user") + require.NoError(t, err) + require.Equal(t, user, who) +} + type noopResolver struct{} func (noopResolver) Resolve(addr string) []string { return []string{addr} } diff --git a/develop/kerberos/.gitignore b/develop/kerberos/.gitignore index f592bb7b6b1..86ef6980907 100644 --- a/develop/kerberos/.gitignore +++ b/develop/kerberos/.gitignore @@ -2,3 +2,5 @@ # mount and are unique per fixture run. They contain principal keys # and should never be committed. keytabs/ +ccache/ +*.ccache diff --git a/develop/kerberos/README.md b/develop/kerberos/README.md index 8b4cdf30577..db82ac063f3 100644 --- a/develop/kerberos/README.md +++ b/develop/kerberos/README.md @@ -13,8 +13,12 @@ brings up two containers: start it creates the `EXAMPLE.LOCAL` realm, registers the principals `postgres/localhost@EXAMPLE.LOCAL` and `temporal@EXAMPLE.LOCAL`, and writes their keytabs into the host - directory `develop/kerberos/keytabs/`. It binds host port 88 (TCP + - UDP). + directory `develop/kerberos/keytabs/`. **On every start** it + refreshes a temporal credential cache at + `develop/kerberos/keytabs/temporal.ccache` by running + `kinit -k -t temporal.keytab temporal@EXAMPLE.LOCAL` against + itself, so a long-running fixture container doesn't serve expired + tickets. It binds host port 88 (TCP + UDP). - **`temporal-dev-krb5-postgres`** — the official `postgres:13.5` image (built `--with-gssapi`) with the postgres keytab and a `pg_hba.conf` that requires `gss` for all TCP connections. It @@ -51,11 +55,20 @@ the default layout: | Env var | Default | | ------------------------- | -------------------------------------------------------------------- | | `TEMPORAL_TEST_KEYTAB` | `develop/kerberos/keytabs/temporal.keytab` (relative to the test) | +| `TEMPORAL_TEST_CCACHE` | `develop/kerberos/keytabs/temporal.ccache` (relative to the test) | | `KRB5_CONFIG` | `develop/kerberos/krb5.conf` (relative to the test) | | `TEMPORAL_TEST_PG_ADDR` | `127.0.0.1:5433` | | `TEMPORAL_TEST_KRB_REALM` | `EXAMPLE.LOCAL` | | `TEMPORAL_TEST_KRB_USER` | `temporal` | +Two tests run by default: + +- `TestKerberosE2E_KeytabAuth` — exercises the keytab credential path + (typical production-server use case). +- `TestKerberosE2E_CCacheAuth` — exercises the credential cache path + (typical CI/automation use case where `kinit` runs outside the + Temporal process). + If the keytab or krb5.conf is missing, the test skips with a clear message instead of failing — so it's safe to run in environments without the fixture. @@ -98,8 +111,3 @@ The `-v` removes the bind volume content reference; the enctypes, or constrained delegation. The unit tests cover the config wiring for these (`disableFAST`); a full AD-equivalent fixture would need Samba 4 or a real AD lab. -- Credential cache (ccache) authentication. The fixture uses keytab - auth because it's the production-server case; ccache works - identically from the test code's perspective if you `kinit` first - and point the test at the resulting cache file via - `kerberos.credentialCacheFile`. diff --git a/develop/kerberos/init-kdc.sh b/develop/kerberos/init-kdc.sh index 4a4dceb40cd..c6014b8a384 100755 --- a/develop/kerberos/init-kdc.sh +++ b/develop/kerberos/init-kdc.sh @@ -35,5 +35,24 @@ else echo "KDC database already exists, skipping initialization." fi +# Refresh the temporal credential cache on every container start so a +# long-running fixture doesn't serve expired tickets. kinit needs the +# KDC reachable, so we briefly run krb5kdc in the background, kinit, +# stop it, then exec the real foreground kdc. +TEMPORAL_CCACHE="${SHARED_DIR}/temporal.ccache" +echo "Starting krb5kdc temporarily (foreground -n) to populate ccache..." +# -n keeps krb5kdc in the foreground; without it the process daemonizes +# and $! captures the parent PID that exits immediately after fork. +krb5kdc -n & +KDC_PID=$! +# Give the KDC a moment to bind its sockets before kinit hits it. +sleep 1 +echo "Refreshing temporal ccache at ${TEMPORAL_CCACHE}..." +KRB5CCNAME="FILE:${TEMPORAL_CCACHE}" kinit -k -t "${TEMPORAL_KEYTAB}" "temporal@${REALM}" +chmod 644 "${TEMPORAL_CCACHE}" +echo "Stopping background krb5kdc (PID ${KDC_PID})..." +kill "${KDC_PID}" +wait "${KDC_PID}" 2>/dev/null || true + echo "Starting krb5kdc in foreground..." exec krb5kdc -n From 7f3bfccc3ce7c034d5728813ff44948c25637a31 Mon Sep 17 00:00:00 2001 From: StephenHanzlik Date: Thu, 30 Apr 2026 14:35:05 -0600 Subject: [PATCH 7/7] Nits for cross-platform compatibility --- .../postgresql/session/kerberos_e2e_test.go | 2 ++ develop/kerberos/.gitattributes | 4 +++ develop/kerberos/README.md | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 develop/kerberos/.gitattributes diff --git a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go index aeb67810be5..f1b7248a248 100644 --- a/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go +++ b/common/persistence/sql/sqlplugin/postgresql/session/kerberos_e2e_test.go @@ -45,6 +45,7 @@ func envOrDefault(name, fallback string) string { return fallback } +// parallelize:ignore - mutates package-level gssProviderOnce func TestKerberosE2E_KeytabAuth(t *testing.T) { resetGSSProviderForTesting(t) // Defaults assume the docker-compose.kerberos.yml fixture is up @@ -97,6 +98,7 @@ func TestKerberosE2E_KeytabAuth(t *testing.T) { require.Equal(t, user, who, "current_user should match the kerberos principal (with realm stripped via include_realm=0)") } +// parallelize:ignore - mutates package-level gssProviderOnce func TestKerberosE2E_CCacheAuth(t *testing.T) { resetGSSProviderForTesting(t) // Same fixture as the keytab test; this exercises the alternate diff --git a/develop/kerberos/.gitattributes b/develop/kerberos/.gitattributes new file mode 100644 index 00000000000..baca10b8cd2 --- /dev/null +++ b/develop/kerberos/.gitattributes @@ -0,0 +1,4 @@ +# Force LF line endings on the shell script regardless of host +# git autocrlf setting. Windows checkouts otherwise break the script +# inside the Linux container. +*.sh text eol=lf diff --git a/develop/kerberos/README.md b/develop/kerberos/README.md index db82ac063f3..cd9abbaee42 100644 --- a/develop/kerberos/README.md +++ b/develop/kerberos/README.md @@ -24,6 +24,36 @@ brings up two containers: `pg_hba.conf` that requires `gss` for all TCP connections. It binds host port 5433. +## Platform support + +The fixture is intended to work on every platform Temporal supports +for development: + +- **Linux x86_64 / arm64** — works with the standard Docker daemon. + Privileged port 88 binding requires the daemon to run as root + (the default). If you use **rootless Docker**, see "Rootless + Docker" below. +- **macOS (Intel / Apple Silicon)** — works with Docker Desktop. The + postgres:13.5 and alpine:3.19 base images both ship multi-arch + manifests including `linux/arm64`, so no emulation runs on M-series + Macs. +- **Windows via WSL2** — clone and run inside the WSL2 distro (per + the project's `CONTRIBUTING.md`). The `.gitattributes` in this + directory pins `*.sh` to LF line endings so git checkouts on a + Windows host don't CRLF-corrupt `init-kdc.sh`. + +### Rootless Docker (Linux) + +Rootless Docker can't bind privileged host ports (< 1024) by +default. Two ways to run the fixture: + +1. Lower the threshold for your user once with + `sudo sysctl net.ipv4.ip_unprivileged_port_start=80` — survives + until reboot, persistent via `/etc/sysctl.d/`. +2. Edit `docker-compose.kerberos.yml` to map the KDC's port 88 to a + high host port (e.g. `"8088:88"`) and update + `develop/kerberos/krb5.conf` to point at `127.0.0.1:8088`. + ## Bring it up ```bash