Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Release History

## Unreleased
- Forward an optional SP-wide workload identity federation client ID through the kernel backend for PAT and OAuth authentication
- Improve telemetry error reporting: driver failures are now categorized by cause instead of reported as a generic error (databricks/databricks-sql-go#414, #415, #417, #419, #424)

## v1.14.0 (2026-07-13)
Expand Down
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9ac3f3d3f3e804d52d8890e890d9f8a8a617ec93
eff8950428f4e6cc9975c663ec919f334962f7d0
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ groups. Telemetry parameters are covered under [Telemetry](#telemetry).
| Personal access token (PAT) | `token:<t>@…`, or `accessToken=` / `authType=Pat` | `WithAccessToken` | Both |
| OAuth machine-to-machine (M2M) | `clientID=`+`clientSecret=` / `authType=OauthM2M` | `WithClientCredentials` | Both |
| OAuth user-to-machine (U2M) | `authType=OauthU2M` | `WithAuthenticator` (u2m) | Both |
| SP-wide workload identity federation | — | `WithKernelIdentityFederationClientID(clientID)` | SEA only |
| Custom token provider / external / static / federated | — | `WithTokenProvider`, `WithExternalToken`, `WithStaticToken`, `WithFederatedTokenProvider*` | Thrift only |

**PAT** (default): supply `token:<pat>@…` in the DSN, or `WithAccessToken`.
Expand All @@ -290,6 +291,8 @@ groups. Telemetry parameters are covered under [Telemetry](#telemetry).

Notes for the SEA/kernel backend:

- `WithKernelIdentityFederationClientID` forwards a non-empty service-principal
client ID with PAT, OAuth M2M, or OAuth U2M to require SP-wide token exchange.
- Custom OAuth **M2M scopes** are rejected on the kernel path (the kernel applies its
own default scopes). Default scopes work on both.
- **U2M** is interactive: on a cache miss, connecting launches the browser and a
Expand Down
16 changes: 12 additions & 4 deletions connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@ func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {
be, err = newKernelBackend(ctx, c.cfg)
} else {
// The experimental WithKernel* options have no Thrift-path equivalent — reject
// them loudly rather than silently ignore, so a caller who sets one (a
// trusted-CA bundle, a hostname-verify skip, a proxy, a retry budget, or a
// CloudFetch chunk cap) and forgets WithUseKernel learns the option had no
// effect instead of connecting as if it were never set. Every WithKernel*
// them loudly rather than silently ignore. Every WithKernel*
// option allocates KernelExperimental, so this one gate covers them all; the
// message names the family rather than a stale subset that drifts as options
// are added.
Expand Down Expand Up @@ -602,6 +599,17 @@ func kernelExperimental(c *config.Config) *config.KernelExperimentalConfig {
return c.KernelExperimental
}

// WithKernelIdentityFederationClientID selects mandatory SP-wide workload
// identity federation for PAT, OAuth M2M, or OAuth U2M authentication. An empty
// client ID leaves BYOT / account-wide federation behavior unchanged.
//
// EXPERIMENTAL, kernel-only: the default (Thrift) backend rejects this at connect.
func WithKernelIdentityFederationClientID(clientID string) ConnOption {
return func(c *config.Config) {
kernelExperimental(c).IdentityFederationClientID = clientID
}
}

// WithKernelDecimalAsFloat makes the kernel path scan top-level DECIMAL columns to
// a lossy float64 instead of the exact fixed-point string. The kernel still
// receives native Arrow Decimal128; this only changes how the Go scanner
Expand Down
3 changes: 3 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ public client. Neither backend exposes a U2M-scopes option.

Experimental kernel-only options (rejected by the default backend; the
WithKernel* prefix marks them experimental):
- WithKernelIdentityFederationClientID(clientID) requires SP-wide workload identity
token exchange for PAT, OAuth M2M, or OAuth U2M. Empty preserves BYOT / account-wide
federation behavior.
- WithKernelTrustedCerts(pem) adds a PEM CA bundle on top of the system roots (for
a re-signing proxy or on-prem CA). Required because the kernel's TLS stack does
not read SSL_CERT_FILE.
Expand Down
4 changes: 2 additions & 2 deletions internal/backend/kernel/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const (
// through to it by setAuth. resolveKernelAuth populates Scopes with the same
// cloud-specific set the Thrift path requests (via oauth.GetScopes) so both
// backends authorize identically; RedirectPort stays zero (no user option, kernel
// default 8020) but is kept so kernel.Auth models the full set_auth_u2m surface —
// default 8030) but is kept so kernel.Auth models the full set_auth_u2m surface —
// a future WithOAuthRedirectPort becomes populating it, not re-plumbing the setter.
// TestSetAuthByMode's "U2M full" case pins the marshalling of both.
type Auth struct {
Expand All @@ -32,7 +32,7 @@ type Auth struct {
ClientID string // M2M + U2M (U2M: the cloud-inferred Go client id)
ClientSecret string // M2M
Scopes []string // U2M — Thrift-parity scopes from oauth.GetScopes; nil → kernel default
RedirectPort uint16 // U2M — no user option today; 0 → kernel default port (8020)
RedirectPort uint16 // U2M — no user option today; 0 → kernel default port (8030)
}

// M2MCredentialsProvider is implemented by the OAuth M2M authenticator to expose
Expand Down
36 changes: 35 additions & 1 deletion internal/backend/kernel/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ func (k *KernelBackend) OpenSession(ctx context.Context) error {
if err := k.setAuth(cfg); err != nil {
return err
}
if err := k.applyIdentityFederation(cfg); err != nil {
return err
}

// User-Agent so query history attributes the kernel path to this driver.
if k.cfg.UserAgent != "" {
Expand Down Expand Up @@ -423,6 +426,22 @@ func (k *KernelBackend) setAuth(cfg *C.KernelSessionConfig) error {
return nil
}

// applyIdentityFederation forwards the optional SP-wide federation client ID.
// It is independent of the selected PAT, M2M, or U2M auth mode.
func (k *KernelBackend) applyIdentityFederation(cfg *C.KernelSessionConfig) error {
if k.cfg.IdentityFederationClientID == "" {
return nil
}
clientID := newCStr(k.cfg.IdentityFederationClientID)
defer clientID.free()
if err := call(func() C.KernelStatusCode {
return C.kernel_session_config_set_identity_federation_client_id(cfg, clientID.c)
}); err != nil {
return fmt.Errorf("kernel: set_identity_federation_client_id: %w", toConnError(err))
}
return nil
}

// joinScopes renders U2M scopes as the comma-separated form the kernel U2M setter
// expects. Empty (no scopes) yields "" so setAuth passes NULL and the kernel
// applies its default scope set.
Expand All @@ -444,6 +463,21 @@ func trySetAuth(auth Auth) error {
return k.setAuth(cfg)
}

// trySetIdentityFederation applies auth and the federation client ID to a
// throwaway config so tagged tests exercise the real C setters together.
func trySetIdentityFederation(cfg Config) error {
var c *C.KernelSessionConfig
if err := call(func() C.KernelStatusCode { return C.kernel_session_config_new(&c) }); err != nil {
return fmt.Errorf("config_new: %w", err)
}
defer C.kernel_session_config_free(c)
k := &KernelBackend{cfg: cfg}
if err := k.setAuth(c); err != nil {
return err
}
return k.applyIdentityFederation(c)
}

// trySetKernelTLS allocates a throwaway session config, applies the experimental
// TLS knobs from cfg to it, and frees it — the analogous test seam to trySetAuth,
// so a tagged test can exercise the real byte-buffer cgo setter (trusted certs)
Expand Down Expand Up @@ -476,7 +510,7 @@ func trySetProxy(cfg Config) error {
// trySetRetry allocates a throwaway session config, applies the retry config from
// cfg to it, and frees it — the analogous test seam to trySetProxy, so a tagged
// test can exercise the real kernel_session_config_set_retry_config cgo setter
// (the 4 knobs, plus the InvalidArgument rejections for a degenerate range) end to
// (the 4 knobs, plus the InvalidArgument rejection for a zero minimum) end to
// end. Not used in production.
func trySetRetry(cfg Config) error {
var c *C.KernelSessionConfig
Expand Down
3 changes: 3 additions & 0 deletions internal/backend/kernel/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type Config struct {
HTTPPath string // e.g. /sql/1.0/warehouses/abc123 (carries ?o= org routing)
WarehouseID string // bare warehouse id; preferred over HTTPPath when set
Auth Auth // PAT / OAuth M2M / OAuth U2M
// IdentityFederationClientID selects mandatory SP-wide workload identity
// federation. Empty preserves BYOT / account-wide behavior.
IdentityFederationClientID string

// UserAgent is forwarded as the User-Agent header so the kernel path is
// attributed to this driver (not the kernel's built-in UA). Empty leaves it unset.
Expand Down
32 changes: 26 additions & 6 deletions internal/backend/kernel/kernel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ func TestSetAuthByMode(t *testing.T) {
}
}

func TestSetIdentityFederationClientID(t *testing.T) {
cases := []struct {
name string
auth Auth
id string
}{
{"PAT", Auth{Mode: AuthPAT, Token: "dapi-x"}, "federation-client"},
{"M2M", Auth{Mode: AuthM2M, ClientID: "cid", ClientSecret: "sec"}, "federation-client"},
{"U2M", Auth{Mode: AuthU2M, ClientID: "u2m-cid"}, "federation-client"},
{"empty omitted", Auth{Mode: AuthPAT, Token: "dapi-x"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := Config{Auth: tc.auth, IdentityFederationClientID: tc.id}
if err := trySetIdentityFederation(cfg); err != nil {
t.Errorf("trySetIdentityFederation(%s) = %v, want nil", tc.name, err)
}
})
}
}

// TestSetKernelTLS exercises the real cgo setters for the experimental kernel-only
// TLS knobs (the byte-buffer trusted-CA bundle + the hostname-skip bool) via the
// trySetKernelTLS seam — proving the (*C.uint8_t, C.size_t) marshalling and the C
Expand Down Expand Up @@ -89,7 +110,7 @@ func TestSetProxy(t *testing.T) {
}{
{"url only", Config{ProxyURL: "http://proxy:3128"}},
{"url + credentials", Config{ProxyURL: "http://proxy:3128", ProxyUsername: "u", ProxyPassword: "p"}},
{"url + bypass", Config{ProxyURL: "http://proxy:3128", ProxyBypassHosts: "localhost,*.internal"}}, //nolint:gosec // G101: test literals, not real credentials
{"url + bypass", Config{ProxyURL: "http://proxy:3128", ProxyBypassHosts: "localhost,*.internal"}}, //nolint:gosec // G101: test literals, not real credentials
{"all fields", Config{ProxyURL: "http://proxy:3128", ProxyUsername: "u", ProxyPassword: "p", ProxyBypassHosts: "localhost,*.internal"}}, //nolint:gosec // G101: test literals, not real credentials
{"none (no-op)", Config{}},
}
Expand All @@ -104,9 +125,8 @@ func TestSetProxy(t *testing.T) {

// TestSetRetry exercises the real kernel_session_config_set_retry_config cgo setter
// via the trySetRetry seam: a valid range succeeds (incl. the disable form,
// MaxRetries=0, and a non-zero overall budget), and a degenerate range (min=0 or
// max<min) is rejected by the kernel as InvalidArgument. A no-op when Config.Retry
// is nil. Proves the 4-arg marshalling and the C signature.
// MaxRetries=0, a non-zero overall budget, and max<min, which the kernel corrects).
// A zero minimum is rejected as InvalidArgument. A no-op when Config.Retry is nil.
func TestSetRetry(t *testing.T) {
cases := []struct {
name string
Expand All @@ -117,9 +137,9 @@ func TestSetRetry(t *testing.T) {
{"disable (0 retries)", Config{Retry: &RetryConfig{MinWait: time.Second, MaxWait: 30 * time.Second, MaxRetries: 0}}, false},
{"with overall budget", Config{Retry: &RetryConfig{MinWait: time.Second, MaxWait: 30 * time.Second, MaxRetries: 4, OverallTimeout: 5 * time.Minute}}, false},
{"none (no-op)", Config{}, false},
// The kernel setter rejects a degenerate range: min==0 and max<min.
// A zero minimum is invalid; max<min is corrected by raising max to min.
{"min zero rejected", Config{Retry: &RetryConfig{MinWait: 0, MaxWait: time.Second, MaxRetries: 3}}, true},
{"max below min rejected", Config{Retry: &RetryConfig{MinWait: 5 * time.Second, MaxWait: time.Second, MaxRetries: 3}}, true},
{"max below min corrected", Config{Retry: &RetryConfig{MinWait: 5 * time.Second, MaxWait: time.Second, MaxRetries: 3}}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
Expand Down
33 changes: 16 additions & 17 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,9 @@ type Config struct {
ThriftProtocolVersion cli_service.TProtocolVersion
ThriftDebugClientProtocol bool

// KernelExperimental carries experimental, kernel-backend-only options that
// have no equivalent on the default (Thrift) path — currently the richer TLS
// surface (a trusted-CA bundle and an independent hostname-skip) the kernel
// exposes over its C ABI. It lives here on Config, NOT on UserConfig, so it
// stays off the stable exported/DSN surface (the same treatment TLSConfig and
// ArrowConfig get). nil means no experimental option was set. The Thrift
// backend rejects a non-nil value loudly; the kernel backend forwards it to
// the kernel C ABI. Mirrors Node's non-exported InternalConnectionOptions /
// Python's underscore-prefixed kwargs.
// KernelExperimental carries kernel-only options with no Thrift equivalent.
// Keeping them off UserConfig avoids expanding the stable DSN surface. The
// Thrift backend rejects a non-nil value; the kernel backend forwards it.
KernelExperimental *KernelExperimentalConfig
}

Expand All @@ -67,6 +61,10 @@ type Config struct {
// the exhaustiveness guard TestKernelExperimentalFieldsClassified asserts this so
// a newly-added field can't slip through unclassified.
type KernelExperimentalConfig struct {
// IdentityFederationClientID selects mandatory SP-wide workload identity
// federation for PAT, OAuth M2M, or OAuth U2M authentication.
IdentityFederationClientID string

// TLSTrustedCertsPEM is a PEM CA bundle added to the kernel's trust store on
// top of the system roots (maps to kernel_session_config_set_tls_trusted_certs).
// Needed because the kernel's rustls stack ignores SSL_CERT_FILE, so a custom
Expand Down Expand Up @@ -121,14 +119,15 @@ func (k *KernelExperimentalConfig) DeepCopy() *KernelExperimentalConfig {
return nil
}
cp := &KernelExperimentalConfig{
TLSSkipHostnameVerify: k.TLSSkipHostnameVerify,
ProxyURL: k.ProxyURL,
ProxyUsername: k.ProxyUsername,
ProxyPassword: k.ProxyPassword,
ProxyBypassHosts: k.ProxyBypassHosts,
RetryOverallTimeout: k.RetryOverallTimeout,
MaxChunksInMemory: k.MaxChunksInMemory,
DecimalAsFloat: k.DecimalAsFloat,
IdentityFederationClientID: k.IdentityFederationClientID,
TLSSkipHostnameVerify: k.TLSSkipHostnameVerify,
ProxyURL: k.ProxyURL,
ProxyUsername: k.ProxyUsername,
ProxyPassword: k.ProxyPassword,
ProxyBypassHosts: k.ProxyBypassHosts,
RetryOverallTimeout: k.RetryOverallTimeout,
MaxChunksInMemory: k.MaxChunksInMemory,
DecimalAsFloat: k.DecimalAsFloat,
}
if k.TLSTrustedCertsPEM != nil {
cp.TLSTrustedCertsPEM = append([]byte(nil), k.TLSTrustedCertsPEM...)
Expand Down
9 changes: 4 additions & 5 deletions kernel_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,10 @@ func buildKernelConfig(cfg *config.Config, kauth kernel.Auth) kernel.Config {
if cfg.TLSConfig != nil && cfg.TLSConfig.InsecureSkipVerify {
kc.TLSSkipVerify = true
}
// Experimental kernel-only TLS knobs (WithKernelTrustedCerts /
// WithKernelSkipHostnameVerify), if any. These have no Thrift-path equivalent
// (the connector rejects them on that path) and are forwarded verbatim to the
// kernel C ABI in OpenSession.
// Experimental kernel-only knobs have no Thrift-path equivalent and are
// forwarded to the kernel backend here.
if ke := cfg.KernelExperimental; ke != nil {
kc.IdentityFederationClientID = ke.IdentityFederationClientID
kc.TLSTrustedCertsPEM = ke.TLSTrustedCertsPEM
kc.TLSSkipHostnameVerify = ke.TLSSkipHostnameVerify
// Kernel-only CloudFetch in-memory-chunk knob (WithKernelMaxChunksInMemory).
Expand Down Expand Up @@ -317,7 +316,7 @@ func resolveKernelAuth(cfg *config.Config) (kernel.Auth, error) {
// kernel applied its own default set (all-apis + offline_access), which a
// workspace whose public client isn't granted all-apis rejects with
// access_denied. RedirectPort is still left zero (no user option; kernel
// default 8020). Passing nil to GetScopes yields the pure cloud-default set.
// default 8030). Passing nil to GetScopes yields the pure cloud-default set.
return kernel.Auth{Mode: kernel.AuthU2M, ClientID: a.U2MClientID(), Scopes: oauth.GetScopes(cfg.Host, nil)}, nil
case nil, *noop.NoopAuth, *pat.PATAuth:
// PAT (or no explicit authenticator). WithAccessToken sets both
Expand Down
23 changes: 23 additions & 0 deletions kernel_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,29 @@ func TestKernelConfigFieldsClassified(t *testing.T) {
// TestKernelExperimentalFieldsClassified only asserts the disposition map, not the
// runtime copy). These run in the default CGO_ENABLED=0 build.
func TestBuildKernelConfig(t *testing.T) {
t.Run("identity federation client ID forwarded for every auth mode", func(t *testing.T) {
for _, auth := range []kernel.Auth{
{Mode: kernel.AuthPAT, Token: "dapi-x"},
{Mode: kernel.AuthM2M, ClientID: "cid", ClientSecret: "secret"},
{Mode: kernel.AuthU2M, ClientID: "u2m-cid"},
} {
c := baseKernelConfig()
c.KernelExperimental = &config.KernelExperimentalConfig{IdentityFederationClientID: "federation-client"}
kc := buildKernelConfig(c, auth)
if kc.IdentityFederationClientID != "federation-client" {
t.Errorf("auth mode %v: IdentityFederationClientID = %q, want %q",
auth.Mode, kc.IdentityFederationClientID, "federation-client")
}
}
})

t.Run("identity federation client ID omitted when unset", func(t *testing.T) {
kc := buildKernelConfig(baseKernelConfig(), kernel.Auth{Mode: kernel.AuthPAT, Token: "dapi-x"})
if kc.IdentityFederationClientID != "" {
t.Errorf("IdentityFederationClientID = %q, want empty", kc.IdentityFederationClientID)
}
})

t.Run("experimental TLS fields forwarded", func(t *testing.T) {
c := baseKernelConfig()
c.KernelExperimental = &config.KernelExperimentalConfig{
Expand Down
Loading
Loading