From 8f0d3ff5533bd04840437d661eff8a546e7c59bd Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 27 Oct 2026 14:59:45 -0300 Subject: [PATCH 1/6] feat(databricks): support workload identity federation Co-authored-by: Cursor --- README.md | 54 +++++- config_schema.json | 36 +++- docs/connector.mdx | 36 +++- docs/docs-info.md | 17 +- pkg/config/conf.gen.go | 18 +- pkg/config/config.go | 17 +- pkg/config/config_test.go | 126 +++++++++++++ pkg/connector/connector.go | 15 +- pkg/databricks/auth.go | 233 ++++++++++++++++++++++- pkg/databricks/auth_test.go | 365 ++++++++++++++++++++++++++++++++++++ 10 files changed, 874 insertions(+), 43 deletions(-) create mode 100644 pkg/config/config_test.go create mode 100644 pkg/databricks/auth_test.go diff --git a/README.md b/README.md index 371bbe5e..f4c77f6c 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ right top corner that will open a dropdown menu with the account ID along other options. Another requirement is to have valid credentials to run the connector with. The -connector authenticates with the OAuth client credentials flow, using an -account-level service principal that works across the account and every -workspace it has access to. +connector authenticates as an account-level service principal that works across +the account and every workspace it has access to. It can prove it is that +service principal in two ways: with a client secret, which is the default, or +with workload identity federation. See [Authentication](#authentication). To set this up, create a service principal and add an OAuth secret (client ID and secret) to it. You can do that by going to the user management tab and @@ -84,13 +85,44 @@ deployment name, or numeric workspace ID, matched case-insensitively. ## Authentication -OAuth is the only authentication method: an account-level service principal's -client ID and secret, supplied through `--databricks-client-id` and -`--databricks-client-secret`. There is no personal access token (PAT) or +The connector always authenticates as an account-level service principal, named +by `--databricks-client-id`. There is no personal access token (PAT) or workspace-token option, and no username/password option. -OAuth requires a reachable account API. If the account API check fails at -startup, the connector fails validation rather than falling back to a +Exactly one of the following credentials must be supplied. Passing more than one +is a configuration error. + +### Client secret (default) + +Pass the service principal's OAuth secret to `--databricks-client-secret` (or +`BATON_DATABRICKS_CLIENT_SECRET`). The connector runs the OAuth client +credentials flow against the account token endpoint. + +### Workload identity federation + +Instead of a long-lived secret, the connector can present an externally issued +JWT that identifies the workload, such as a SPIFFE JWT-SVID or a Kubernetes +projected service account token, and exchange it for a short-lived Databricks +OAuth token ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). + +Create a [service principal federation policy](https://docs.databricks.com/aws/en/dev-tools/auth/oauth-federation-exchange) +on the same service principal, trusting the issuer, subject and audience of that +JWT. Then supply the JWT with one of: + +- `--databricks-token-file` (or `BATON_DATABRICKS_TOKEN_FILE`): a path to the + token. The file is read again on every token refresh, so a rotated token is + picked up without restarting the connector. Prefer this for anything + long-running. +- `--databricks-token` (or `BATON_DATABRICKS_TOKEN`): the JWT itself. The + connector cannot refresh it, so the sync fails once it expires. + +Nothing else changes: the exchange happens at the same account token endpoint, +and the resulting token is used for both account and workspace APIs. + +### Validation + +Either method requires a reachable account API. If the account API check fails +at startup, the connector fails validation rather than falling back to a workspace-only sync, even when `--workspaces` is set. To instead exclude specific workspaces from the sync, pass them to the @@ -101,7 +133,7 @@ ID. Excluded workspaces and their roles are skipped entirely. ## Group provisioning -Account groups are provisioned through the OAuth client ID and secret flow. +Account groups are provisioned as the authenticated service principal. [Here](https://docs.databricks.com/aws/en/admin/users-groups/groups#:~:text=Types%20of%20groups%20in%20Databricks,permissions%20to%20identity%20federated%20workspaces.) are the different types of groups in Databricks. @@ -154,8 +186,10 @@ Flags: --client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID) --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) --databricks-client-id string required: The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_ID) - --databricks-client-secret string required: The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) + --databricks-client-secret string The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) --databricks-exclude-workspaces strings Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces. ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) + --databricks-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. ($BATON_DATABRICKS_TOKEN) + --databricks-token-file string Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh. ($BATON_DATABRICKS_TOKEN_FILE) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS) diff --git a/config_schema.json b/config_schema.json index e387c1ed..05646382 100644 --- a/config_schema.json +++ b/config_schema.json @@ -124,13 +124,21 @@ "name": "databricks-client-secret", "displayName": "OAuth2 Client Secret", "description": "The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API", - "isRequired": true, "isSecret": true, - "stringField": { - "rules": { - "isRequired": true - } - } + "stringField": {} + }, + { + "name": "databricks-token", + "displayName": "Workload Identity Token", + "description": "An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token.", + "isSecret": true, + "stringField": {} + }, + { + "name": "databricks-token-file", + "displayName": "Workload Identity Token File", + "description": "Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh.", + "stringField": {} }, { "name": "hostname", @@ -160,6 +168,22 @@ "workspaces", "databricks-exclude-workspaces" ] + }, + { + "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", + "fieldNames": [ + "databricks-client-secret", + "databricks-token", + "databricks-token-file" + ] + }, + { + "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", + "fieldNames": [ + "databricks-client-secret", + "databricks-token", + "databricks-token-file" + ] } ], "displayName": "Databricks", diff --git a/docs/connector.mdx b/docs/connector.mdx index f57273e3..5b017cc9 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -29,7 +29,12 @@ Revoking the workspace **member** entitlement removes the principal's entire wor ## Authentication methods -The connector authenticates with **OAuth** — an account-level service principal's client ID and secret. This is the only authentication method the connector supports. +The connector authenticates as an account-level service principal, named by its client ID. It proves it is that service principal in one of two ways: + +- **OAuth client secret** (default): the service principal's client ID and secret. +- **Workload identity federation**: the connector presents an externally issued JWT that identifies the workload, such as a SPIFFE JWT-SVID or a Kubernetes projected service account token, and Databricks exchanges it for a short-lived token. No long-lived secret is stored, and the Databricks administrator controls access through a service principal federation policy. This fits a deployment where the platform running the connector already issues it a workload identity token. + +Exactly one of the two credentials is supplied. There is no personal access token (PAT), workspace token, or username and password option. ## Gather Databricks credentials @@ -55,7 +60,7 @@ A user with the **Account admin** role in each Databricks workspace you want to ### Generate Databricks credentials -The Databricks connector authenticates with OAuth, which syncs info from all Databricks workspaces the service principal can access. +The Databricks connector authenticates as a service principal, which syncs info from all Databricks workspaces that service principal can access. @@ -72,6 +77,24 @@ The Databricks connector authenticates with OAuth, which syncs info from all Dat - OAuth client ID - OAuth client secret +### Use workload identity federation instead of a secret + +You can skip the OAuth secret and have the connector present an externally issued JWT instead. The connector must be able to read that JWT where it runs, so this suits a deployment whose platform injects a workload identity token. + + + + Create the service principal as described above, but do not generate an OAuth secret. + + + Follow the [Databricks OAuth token federation documentation](https://docs.databricks.com/aws/en/dev-tools/auth/oauth-federation-exchange) to add a service principal federation policy that trusts the issuer, subject and audience of the JWT your workload receives. + + + Make the JWT available to the connector, ideally as a file that your platform rotates. + + + +**Done.** In this case the credentials are the account ID, the OAuth client ID, and the JWT, in place of the OAuth client secret. + Next, move on to the instructions for your chosen setup method. ## Configure the Databricks connector @@ -204,6 +227,13 @@ stringData: BATON_DATABRICKS_CLIENT_ID: BATON_DATABRICKS_CLIENT_SECRET: + # Alternative to BATON_DATABRICKS_CLIENT_SECRET: workload identity federation. + # Point this at a projected service account token, or any other externally + # issued JWT your federation policy trusts. The connector reads the file again + # on every token refresh, so a rotated token needs no restart. + # Set either the client secret or one of the token options, never more than one. + # BATON_DATABRICKS_TOKEN_FILE: /var/run/secrets/databricks/token + # Optional: limit the sync to specific workspaces # (workspace name, deployment name, or numeric ID). # Mutually exclusive with BATON_DATABRICKS_EXCLUDE_WORKSPACES — set one or the other, never both. @@ -223,7 +253,7 @@ stringData: -OAuth requires a reachable account API. If the account API check fails at startup, the connector fails validation instead of falling back to a workspace-only sync, even when `BATON_WORKSPACES` is set. +Both credential options require a reachable account API. If the account API check fails at startup, the connector fails validation instead of falling back to a workspace-only sync, even when `BATON_WORKSPACES` is set. See the connector's README or run `--help` to see all available configuration flags and environment variables. diff --git a/docs/docs-info.md b/docs/docs-info.md index fcebe05c..aa6dafbf 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -17,28 +17,31 @@ While developing the connector, please fill out this form. This information is n 1. What credentials or information are needed to set up the connector? (For example, API key, client ID and secret, domain, etc.) - > The connector requires a Databricks account ID and OAuth credentials: + > The connector requires a Databricks account ID and a service principal OAuth client ID. It syncs the account and every workspace that service principal can access. The service principal is then authenticated in one of two ways: > - > - **OAuth**: a service principal OAuth client ID and client secret. Syncs the account and every workspace the service principal can access. This is the connector's only authentication method. + > - **OAuth client secret** (default): the service principal's OAuth client secret. + > - **Workload identity federation**: an externally issued JWT identifying the workload, such as a SPIFFE JWT-SVID or a Kubernetes projected service account token, supplied as a value or as a file path. The connector exchanges it for a short-lived Databricks token, so no long-lived secret is stored. This requires a service principal federation policy in Databricks. + > + > Exactly one of the two credentials is supplied. > > Google Cloud Platform and Azure Databricks customers also provide the account hostname and hostname. 2. For each item in the list above: - - * How does a user create or look up that credential or info? Please include links to (non-gated) documentation, screenshots (of the UI or of gated docs), or a video of the process. + - How does a user create or look up that credential or info? Please include links to (non-gated) documentation, screenshots (of the UI or of gated docs), or a video of the process. > - **Account ID**: in the Databricks account console, open the menu next to your username in the upper-right corner; the account ID is shown there. > - **OAuth client ID and secret**: follow the [Databricks OAuth (M2M) documentation](https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html) to create a service principal and generate an OAuth secret. + > - **Workload identity federation**: follow the [Databricks OAuth token federation documentation](https://docs.databricks.com/aws/en/dev-tools/auth/oauth-federation-exchange) to add a federation policy to that same service principal. The JWT itself comes from the workload's own platform, not from Databricks. > - **Deployment name**: the subdomain in the workspace URL (not the numeric workspace ID). - * Does the credential need any specific scopes or permissions? If so, list them here. + - Does the credential need any specific scopes or permissions? If so, list them here. > The credential must have admin access to each resource it reads or writes: account-admin on the Databricks account (for account-level sync and provisioning) and workspace-admin on each workspace being synced. - * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. + - If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. > No separate scopes: Databricks admin access covers both read (sync) and read-write (provision). - * What level of access or permissions does the user need in order to create the credentials? (For example, must be a super administrator, must have access to the admin console, etc.) + - What level of access or permissions does the user need in order to create the credentials? (For example, must be a super administrator, must have access to the admin console, etc.) > Account admin access to the Databricks account console (to create the service principal and OAuth secret), and workspace admin on each workspace being synced. diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index dd2a3bba..f619ced4 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -1,16 +1,18 @@ // Code generated by baton-sdk. DO NOT EDIT!!! package config -import "reflect" +import "reflect" type Databricks struct { - AccountHostname string `mapstructure:"account-hostname"` - AccountId string `mapstructure:"account-id"` - DatabricksClientId string `mapstructure:"databricks-client-id"` - DatabricksClientSecret string `mapstructure:"databricks-client-secret"` - Hostname string `mapstructure:"hostname"` - Workspaces []string `mapstructure:"workspaces"` - BaseUrl string `mapstructure:"base-url"` + AccountHostname string `mapstructure:"account-hostname"` + AccountId string `mapstructure:"account-id"` + DatabricksClientId string `mapstructure:"databricks-client-id"` + DatabricksClientSecret string `mapstructure:"databricks-client-secret"` + DatabricksToken string `mapstructure:"databricks-token"` + DatabricksTokenFile string `mapstructure:"databricks-token-file"` + Hostname string `mapstructure:"hostname"` + Workspaces []string `mapstructure:"workspaces"` + BaseUrl string `mapstructure:"base-url"` DatabricksExcludeWorkspaces []string `mapstructure:"databricks-exclude-workspaces"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index 35afe969..9ae85f4a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,13 +17,24 @@ var ( field.WithDisplayName("OAuth2 Client ID"), field.WithRequired(true), ) + // Not required: FieldsMutuallyExclusive rejects a Required field. DatabricksClientSecretField = field.StringField( "databricks-client-secret", field.WithDescription("The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API"), field.WithIsSecret(true), - field.WithRequired(true), field.WithDisplayName("OAuth2 Client Secret"), ) + DatabricksTokenField = field.StringField( + "databricks-token", + field.WithDescription("An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token."), + field.WithIsSecret(true), + field.WithDisplayName("Workload Identity Token"), + ) + DatabricksTokenFileField = field.StringField( + "databricks-token-file", + field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh."), + field.WithDisplayName("Workload Identity Token File"), + ) WorkspacesField = field.StringSliceField( "workspaces", field.WithDescription( @@ -59,6 +70,8 @@ var ( AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, + DatabricksTokenField, + DatabricksTokenFileField, HostnameField, WorkspacesField, BaseURLField, @@ -74,5 +87,7 @@ var Config = field.NewConfiguration( field.WithIconUrl("/static/app-icons/databricks.svg"), field.WithConstraints( field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField), + field.FieldsMutuallyExclusive(DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField), + field.FieldsAtLeastOneUsed(DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField), ), ) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..f2966e89 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "strings" + "testing" + + "github.com/conductorone/baton-sdk/pkg/field" +) + +// An Invalid relationship makes field.Validate reject every configuration with a +// bare "invalid relationship constraint", so the connector would not start at all. +func TestCredentialConstraintsAreWellFormed(t *testing.T) { + var mutuallyExclusive, atLeastOne int + + for _, constraint := range Config.Constraints { + if constraint.Kind == field.Invalid { + t.Fatalf("constraint %+v is Invalid and will never be enforced", constraint) + } + + if !constraintCovers(constraint, DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField) { + continue + } + + switch constraint.Kind { + case field.MutuallyExclusive: + mutuallyExclusive++ + case field.AtLeastOne: + atLeastOne++ + case field.Invalid, field.RequiredTogether, field.Dependents: + } + } + + if mutuallyExclusive != 1 { + t.Errorf("found %d mutually exclusive credential constraints, want 1", mutuallyExclusive) + } + if atLeastOne != 1 { + t.Errorf("found %d at-least-one credential constraints, want 1", atLeastOne) + } +} + +func constraintCovers(constraint field.SchemaFieldRelationship, want ...field.SchemaField) bool { + if len(constraint.Fields) != len(want) { + return false + } + + present := make(map[string]bool, len(constraint.Fields)) + for _, f := range constraint.Fields { + present[f.FieldName] = true + } + + for _, f := range want { + if !present[f.FieldName] { + return false + } + } + + return true +} + +func TestCredentialValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Databricks + wantErr string + }{ + { + name: "client secret alone", + cfg: Databricks{DatabricksClientSecret: "secret"}, + }, + { + name: "workload identity token alone", + cfg: Databricks{DatabricksToken: "subject-jwt"}, + }, + { + name: "workload identity token file alone", + cfg: Databricks{DatabricksTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. + }, + { + name: "no credential", + cfg: Databricks{}, + wantErr: "at least one", + }, + { + name: "client secret and token together", + cfg: Databricks{DatabricksClientSecret: "secret", DatabricksToken: "subject-jwt"}, + wantErr: "mutually exclusive", + }, + { + name: "token and token file together", + cfg: Databricks{DatabricksToken: "subject-jwt", DatabricksTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: test fixtures, not credentials. + wantErr: "mutually exclusive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := tt.cfg + cfg.AccountId = "acc-1" + cfg.DatabricksClientId = "sp-client-id" + + err := field.Validate(Config, &cfg) + + switch { + case tt.wantErr == "" && err != nil: + t.Errorf("Validate returned %v, want no error", err) + case tt.wantErr != "" && err == nil: + t.Errorf("Validate succeeded, want an error containing %q", tt.wantErr) + case tt.wantErr != "" && !strings.Contains(strings.ToLower(err.Error()), tt.wantErr): + t.Errorf("Validate returned %q, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestClientSecretIsNotRequired(t *testing.T) { + if DatabricksClientSecretField.Required { + t.Error("databricks-client-secret is Required, which makes the mutually exclusive constraint Invalid") + } + if !DatabricksClientIdField.Required { + t.Error("databricks-client-id must stay required: federation is per service principal") + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f97021e6..1f2be7d5 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -168,16 +168,27 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorO return cb, nil, nil } +// prepareClientAuth uses the client secret by default and switches to workload +// identity federation when one of the token fields is set. func prepareClientAuth(cfg *config.Databricks) databricks.Auth { accountID := cfg.AccountId databricksClientId := cfg.DatabricksClientId - databricksClientSecret := cfg.DatabricksClientSecret accountHostname := getAccountHostname(cfg, cfg.Hostname) + if cfg.DatabricksToken != "" || cfg.DatabricksTokenFile != "" { + return databricks.NewTokenFederation( + accountID, + databricksClientId, + cfg.DatabricksToken, + cfg.DatabricksTokenFile, + accountHostname, + ) + } + return databricks.NewOAuth2( accountID, databricksClientId, - databricksClientSecret, + cfg.DatabricksClientSecret, accountHostname, ) } diff --git a/pkg/databricks/auth.go b/pkg/databricks/auth.go index 63cda5bc..89767dbe 100644 --- a/pkg/databricks/auth.go +++ b/pkg/databricks/auth.go @@ -2,15 +2,52 @@ package databricks import ( "context" + "encoding/base64" + "encoding/json" "fmt" "net/http" + "net/url" + "os" + "strings" + "time" "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" + "google.golang.org/grpc/codes" ) +const ( + scopeAllAPIs = "all-apis" + + tokenPathOIDC = "oidc" + tokenPathAccounts = "accounts" + tokenPathVersion = "v1" + tokenPathToken = "token" + + //nolint:gosec // G101: these are the public RFC 8693 URNs, not credentials. + grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + //nolint:gosec // G101: these are the public RFC 8693 URNs, not credentials. + subjectTokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" + + tokenExchangeTimeout = 30 * time.Second + + fallbackTokenLifetime = 5 * time.Minute +) + +type tokenExchangeError struct { + Code string `json:"error"` + Description string `json:"error_description"` + RequestID string `json:"request_id"` +} + +type tokenExchangeResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + type Auth interface { Apply(req *http.Request) GetClient(ctx context.Context) (*http.Client, error) @@ -18,6 +55,103 @@ type Auth interface { type NoAuth struct{} +type OAuth2 struct { + cfg *clientcredentials.Config +} + +type TokenFederation struct { + clientID string + tokenURL string + token string + tokenFile string +} + +type federationTokenSource struct { + ctx context.Context + auth *TokenFederation + httpClient *uhttp.BaseHttpClient +} + +func (e *tokenExchangeError) Message() string { + msg := e.Description + if msg == "" { + msg = e.Code + } + if e.RequestID != "" { + msg = fmt.Sprintf("%s (request id %s)", msg, e.RequestID) + } + + return msg +} + +// accountTokenURL is the account-level token endpoint, shared by both auth methods. +func accountTokenURL(accountHostname, accountID string) string { + u := &url.URL{Scheme: "https", Host: accountHostname} + + return u.JoinPath(tokenPathOIDC, tokenPathAccounts, accountID, tokenPathVersion, tokenPathToken).String() +} + +// subjectToken returns the JWT. A file is read on every call, so a rotated token is +// picked up without restarting the connector. +func (t *TokenFederation) subjectToken() (string, error) { + if t.tokenFile == "" { + if t.token == "" { + return "", uhttp.WrapErrors(codes.FailedPrecondition, "no workload identity token configured") + } + + return t.token, nil + } + + raw, err := os.ReadFile(t.tokenFile) + if err != nil { + return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("failed to read workload identity token file %q", t.tokenFile), err) + } + + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("workload identity token file %q is empty", t.tokenFile)) + } + + return token, nil +} + +// tokenExpiry never returns the zero time, which oauth2 reads as a token that +// never expires. +func tokenExpiry(now time.Time, expiresIn int64, accessToken string) time.Time { + if expiresIn > 0 { + return now.Add(time.Duration(expiresIn) * time.Second) + } + + if exp, ok := jwtExpiry(accessToken); ok && exp.After(now) { + return exp + } + + return now.Add(fallbackTokenLifetime) +} + +// jwtExpiry reads the exp claim without verifying the signature. The claim only +// decides when to exchange again. +func jwtExpiry(token string) (time.Time, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, false + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, false + } + + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp <= 0 { + return time.Time{}, false + } + + return time.Unix(claims.Exp, 0), true +} + func (n *NoAuth) Apply(req *http.Request) {} func (n *NoAuth) GetClient(ctx context.Context) (*http.Client, error) { @@ -29,17 +163,13 @@ func (n *NoAuth) GetClient(ctx context.Context) (*http.Client, error) { return httpClient, nil } -type OAuth2 struct { - cfg *clientcredentials.Config -} - func NewOAuth2(accId, clientId, clientSecret, accountHostname string) *OAuth2 { return &OAuth2{ cfg: &clientcredentials.Config{ ClientID: clientId, ClientSecret: clientSecret, - TokenURL: fmt.Sprintf("https://%s/oidc/accounts/%s/v1/token", accountHostname, accId), - Scopes: []string{"all-apis"}, + TokenURL: accountTokenURL(accountHostname, accId), + Scopes: []string{scopeAllAPIs}, }, } } @@ -54,3 +184,94 @@ func (o *OAuth2) GetClient(ctx context.Context) (*http.Client, error) { func (o *OAuth2) Apply(req *http.Request) { // No need to set the Authorization header here, the oauth2 client does it automatically } + +// NewTokenFederation builds the federation auth. Exactly one of token or tokenFile +// carries the JWT. +func NewTokenFederation(accID, clientID, token, tokenFile, accountHostname string) *TokenFederation { + return &TokenFederation{ + clientID: clientID, + tokenURL: accountTokenURL(accountHostname, accID), + token: token, + tokenFile: tokenFile, + } +} + +func (t *TokenFederation) GetClient(ctx context.Context) (*http.Client, error) { + httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) + if err != nil { + return nil, err + } + + exchangeClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient) + if err != nil { + return nil, err + } + + source := &federationTokenSource{ctx: ctx, auth: t, httpClient: exchangeClient} + + return oauth2.NewClient(ctx, oauth2.ReuseTokenSource(nil, source)), nil +} + +func (t *TokenFederation) Apply(req *http.Request) {} + +// Token exchanges the JWT for a short-lived Databricks OAuth token. +// POST https://{accountHostname}/oidc/accounts/{accountId}/v1/token, RFC 8693. +// It needs a service principal federation policy trusting the JWT's issuer, subject and audience. +// The context is the one the client was built with, because oauth2.TokenSource takes none. +func (s *federationTokenSource) Token() (*oauth2.Token, error) { + subjectToken, err := s.auth.subjectToken() + if err != nil { + return nil, err + } + + form := url.Values{ + "grant_type": {grantTypeTokenExchange}, + "subject_token": {subjectToken}, + "subject_token_type": {subjectTokenTypeJWT}, + "client_id": {s.auth.clientID}, + "scope": {scopeAllAPIs}, + } + + ctx, cancel := context.WithTimeout(s.ctx, tokenExchangeTimeout) + defer cancel() + + endpoint, err := url.Parse(s.auth.tokenURL) + if err != nil { + return nil, fmt.Errorf("failed to parse the token endpoint: %w", err) + } + + req, err := s.httpClient.NewRequest(ctx, http.MethodPost, endpoint, + uhttp.WithFormBody(form.Encode()), + uhttp.WithAcceptJSONHeader(), + ) + if err != nil { + return nil, fmt.Errorf("failed to build the token exchange request: %w", err) + } + + var exchanged tokenExchangeResponse + var exchangeErr tokenExchangeError + + resp, err := s.httpClient.Do(req, + uhttp.WithJSONResponse(&exchanged), + uhttp.WithErrorResponse(&exchangeErr), + ) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if exchanged.AccessToken == "" { + return nil, uhttp.WrapErrors(codes.FailedPrecondition, "token exchange returned no access token") + } + + tokenType := exchanged.TokenType + if tokenType == "" { + tokenType = "Bearer" + } + + return &oauth2.Token{ + AccessToken: exchanged.AccessToken, + TokenType: tokenType, + Expiry: tokenExpiry(time.Now(), exchanged.ExpiresIn, exchanged.AccessToken), + }, nil +} diff --git a/pkg/databricks/auth_test.go b/pkg/databricks/auth_test.go new file mode 100644 index 00000000..2aaaf085 --- /dev/null +++ b/pkg/databricks/auth_test.go @@ -0,0 +1,365 @@ +package databricks + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// exchangeServer serves the token endpoint and records the last form it received. +func exchangeServer(t *testing.T, handler func(form url.Values) (int, string)) (*httptest.Server, *url.Values) { + t.Helper() + + received := &url.Values{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + *received = r.PostForm + + status, body := handler(r.PostForm) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write([]byte(body)); err != nil { + t.Errorf("write response: %v", err) + } + })) + t.Cleanup(server.Close) + + return server, received +} + +func newTestTokenSource(t *testing.T, tokenURL, token, tokenFile string) *federationTokenSource { + t.Helper() + + ctx := context.Background() + httpClient, err := uhttp.NewBaseHttpClientWithContext(ctx, &http.Client{}) + if err != nil { + t.Fatalf("NewBaseHttpClientWithContext: %v", err) + } + + return &federationTokenSource{ + ctx: ctx, + auth: &TokenFederation{clientID: "sp-client-id", tokenURL: tokenURL, token: token, tokenFile: tokenFile}, + httpClient: httpClient, + } +} + +func writeTokenFile(t *testing.T, contents string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "token.jwt") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + return path +} + +func TestTokenFederationExchange(t *testing.T) { + server, received := exchangeServer(t, func(url.Values) (int, string) { + return http.StatusOK, `{"access_token":"databricks-token","token_type":"Bearer","expires_in":3600}` + }) + + source := newTestTokenSource(t, server.URL, "subject-jwt", "") + + token, err := source.Token() + if err != nil { + t.Fatalf("Token: %v", err) + } + + if token.AccessToken != "databricks-token" { + t.Errorf("AccessToken = %q, want %q", token.AccessToken, "databricks-token") + } + if token.TokenType != "Bearer" { + t.Errorf("TokenType = %q, want %q", token.TokenType, "Bearer") + } + // A zero Expiry passes token.Valid(), so assert the value itself. + if d := time.Until(token.Expiry); d < 55*time.Minute || d > time.Hour { + t.Errorf("token expires in %v, want about an hour from now", d) + } + + want := map[string]string{ + "grant_type": grantTypeTokenExchange, + "subject_token": "subject-jwt", + "subject_token_type": subjectTokenTypeJWT, + "client_id": "sp-client-id", + "scope": scopeAllAPIs, + } + for key, value := range want { + if got := received.Get(key); got != value { + t.Errorf("form %q = %q, want %q", key, got, value) + } + } +} + +// A rotated token file must be picked up without restarting the connector. +func TestTokenFederationRereadsTokenFile(t *testing.T) { + server, received := exchangeServer(t, func(url.Values) (int, string) { + return http.StatusOK, `{"access_token":"databricks-token","expires_in":3600}` + }) + + path := writeTokenFile(t, "first-jwt\n") + source := newTestTokenSource(t, server.URL, "", path) + + if _, err := source.Token(); err != nil { + t.Fatalf("first Token: %v", err) + } + if got := received.Get("subject_token"); got != "first-jwt" { + t.Fatalf("first subject_token = %q, want %q (trailing newline must be trimmed)", got, "first-jwt") + } + + if err := os.WriteFile(path, []byte("rotated-jwt"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := source.Token(); err != nil { + t.Fatalf("second Token: %v", err) + } + if got := received.Get("subject_token"); got != "rotated-jwt" { + t.Errorf("second subject_token = %q, want %q", got, "rotated-jwt") + } +} + +func TestTokenFederationExchangeErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + body string + wantErr string + }{ + { + name: "rejected by the federation policy", + status: http.StatusUnauthorized, + body: `{"error":"invalid_request","error_description":"no matching federation policy"}`, + wantErr: "no matching federation policy", + }, + { + name: "response without an access token", + status: http.StatusOK, + body: `{"token_type":"Bearer","expires_in":3600}`, + wantErr: "no access token", + }, + { + name: "response that is not JSON", + status: http.StatusOK, + body: `gateway`, + wantErr: "unmarshal json response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, _ := exchangeServer(t, func(url.Values) (int, string) { + return tt.status, tt.body + }) + + _, err := newTestTokenSource(t, server.URL, "subject-jwt", "").Token() + if err == nil { + t.Fatal("Token succeeded, want an error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +// A failed exchange must reach the syncer with a gRPC code. An unclassified error +// reads as Unknown, so a throttled token endpoint would end the sync instead of retrying. +func TestTokenFederationExchangeErrorsAreClassified(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + want codes.Code + }{ + {"rejected by the federation policy", http.StatusBadRequest, codes.InvalidArgument}, + {"bad credentials", http.StatusUnauthorized, codes.Unauthenticated}, + {"forbidden", http.StatusForbidden, codes.PermissionDenied}, + {"throttled stays retryable", http.StatusTooManyRequests, codes.Unavailable}, + {"server error stays retryable", http.StatusServiceUnavailable, codes.Unavailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, _ := exchangeServer(t, func(url.Values) (int, string) { + return tt.status, `{"error":"invalid_grant","error_description":"no matching federation policy"}` + }) + + _, err := newTestTokenSource(t, server.URL, "subject-jwt", "").Token() + if err == nil { + t.Fatal("Token succeeded, want an error") + } + + if got := status.Code(err); got != tt.want { + t.Errorf("grpc code = %v, want %v", got, tt.want) + } + + // wrapTransportAuthError runs on this error at the API call sites and must + // not downgrade the code. + if got := status.Code(wrapTransportAuthError(err)); got != tt.want { + t.Errorf("grpc code after wrapTransportAuthError = %v, want %v", got, tt.want) + } + + if !strings.Contains(err.Error(), "no matching federation policy") { + t.Errorf("error = %q, want it to keep the vendor's reason", err) + } + }) + } +} + +func TestTokenFederationSubjectToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + wantErr string + }{ + {"missing file", filepath.Join(t.TempDir(), "absent.jwt"), "failed to read"}, + {"empty file", writeTokenFile(t, " \n"), "is empty"}, + {"no credential at all", "", "no workload identity token configured"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := newTestTokenSource(t, "", "", tt.file).Token() + if err == nil { + t.Fatal("Token succeeded, want an error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want it to contain %q", err, tt.wantErr) + } + if got := status.Code(err); got != codes.FailedPrecondition { + t.Errorf("grpc code = %v, want %v", got, codes.FailedPrecondition) + } + }) + } +} + +// A zero Expiry reads as "never expires" in oauth2, pinning the first exchange. +func TestTokenExpiry(t *testing.T) { + t.Parallel() + + // A token that carries no exp claim to fall back to. + const notAJWT = "an-opaque-string" + + now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + expiresIn int64 + accessToken string + want time.Time + }{ + { + name: "expires_in wins", + expiresIn: 3600, + want: now.Add(time.Hour), + }, + { + name: "falls back to the exp claim", + accessToken: testJWT(t, now.Add(30*time.Minute)), + want: now.Add(30 * time.Minute), + }, + { + name: "falls back to a short lifetime for an opaque token", + accessToken: notAJWT, + want: now.Add(fallbackTokenLifetime), + }, + { + name: "falls back to a short lifetime when the exp claim is in the past", + accessToken: testJWT(t, now.Add(-time.Hour)), + want: now.Add(fallbackTokenLifetime), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tokenExpiry(now, tt.expiresIn, tt.accessToken); !got.Equal(tt.want) { + t.Errorf("tokenExpiry = %v, want %v", got, tt.want) + } + }) + } +} + +func testJWT(t *testing.T, expiry time.Time) string { + t.Helper() + + claims, err := json.Marshal(map[string]int64{"exp": expiry.Unix()}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + encode := base64.RawURLEncoding.EncodeToString + + return fmt.Sprintf("%s.%s.%s", encode([]byte(`{"alg":"RS256"}`)), encode(claims), encode([]byte("signature"))) +} + +func TestAccountTokenURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accountHostname string + accountID string + want string + }{ + { + name: "account id", + accountHostname: "accounts.cloud.databricks.com", + accountID: "8c6f99ec-78ce-4654-8f92-e716b3dd67a7", + want: "https://accounts.cloud.databricks.com/oidc/accounts/8c6f99ec-78ce-4654-8f92-e716b3dd67a7/v1/token", + }, + { + name: "azure account host", + accountHostname: "accounts.azuredatabricks.net", + accountID: "acc-1", + want: "https://accounts.azuredatabricks.net/oidc/accounts/acc-1/v1/token", + }, + { + // A '?' must be escaped, or it truncates the path before /v1/token. + name: "account id is escaped", + accountHostname: "accounts.cloud.databricks.com", + accountID: "acc?x=1", + want: "https://accounts.cloud.databricks.com/oidc/accounts/acc%3Fx=1/v1/token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := accountTokenURL(tt.accountHostname, tt.accountID); got != tt.want { + t.Errorf("accountTokenURL = %q, want %q", got, tt.want) + } + }) + } +} From 86b0356dcfa16f3286f96090e63e2fde2d6a1ea9 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 28 Oct 2026 09:02:06 -0300 Subject: [PATCH 2/6] docs(databricks): warn that federation credentials do not refresh themselves Co-authored-by: Cursor --- README.md | 4 ++-- config_schema.json | 4 ++-- docs/connector.mdx | 6 ++++++ pkg/config/config.go | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f4c77f6c..90c630ce 100644 --- a/README.md +++ b/README.md @@ -188,8 +188,8 @@ Flags: --databricks-client-id string required: The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_ID) --databricks-client-secret string The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) --databricks-exclude-workspaces strings Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces. ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) - --databricks-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. ($BATON_DATABRICKS_TOKEN) - --databricks-token-file string Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh. ($BATON_DATABRICKS_TOKEN_FILE) + --databricks-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires. ($BATON_DATABRICKS_TOKEN) + --databricks-token-file string Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh. ($BATON_DATABRICKS_TOKEN_FILE) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS) diff --git a/config_schema.json b/config_schema.json index 05646382..488da4af 100644 --- a/config_schema.json +++ b/config_schema.json @@ -130,14 +130,14 @@ { "name": "databricks-token", "displayName": "Workload Identity Token", - "description": "An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token.", + "description": "An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires.", "isSecret": true, "stringField": {} }, { "name": "databricks-token-file", "displayName": "Workload Identity Token File", - "description": "Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh.", + "description": "Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh.", "stringField": {} }, { diff --git a/docs/connector.mdx b/docs/connector.mdx index 5b017cc9..3aa41f8e 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -36,6 +36,12 @@ The connector authenticates as an account-level service principal, named by its Exactly one of the two credentials is supplied. There is no personal access token (PAT), workspace token, or username and password option. + +The token file must exist on the machine that runs the connector. A hosted connector has no file for you to rotate, so use the token file only when you run the connector yourself. + +A token pasted into **Workload Identity Token** is not refreshed. The sync fails once that JWT expires. Prefer the token file, which the connector reads again on every refresh. + + ## Gather Databricks credentials Configuring the connector requires you to pass in credentials generated in Databricks. Gather these credentials before you move on. diff --git a/pkg/config/config.go b/pkg/config/config.go index 9ae85f4a..150fe26a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,13 +26,13 @@ var ( ) DatabricksTokenField = field.StringField( "databricks-token", - field.WithDescription("An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token."), + field.WithDescription("An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires."), field.WithIsSecret(true), field.WithDisplayName("Workload Identity Token"), ) DatabricksTokenFileField = field.StringField( "databricks-token-file", - field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. Read again on every token refresh."), + field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh."), field.WithDisplayName("Workload Identity Token File"), ) WorkspacesField = field.StringSliceField( From 5bebc2e6d28183151797c271c0b43d04cbbfe6f5 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 30 Oct 2026 12:16:41 -0300 Subject: [PATCH 3/6] chore(databricks): remove redundant config comment --- pkg/config/config.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 150fe26a..11b3caf3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,7 +17,6 @@ var ( field.WithDisplayName("OAuth2 Client ID"), field.WithRequired(true), ) - // Not required: FieldsMutuallyExclusive rejects a Required field. DatabricksClientSecretField = field.StringField( "databricks-client-secret", field.WithDescription("The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API"), From 6f16f41ec0cd918d190057dfe188aac1b73c3339 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Mon, 2 Nov 2026 09:41:08 -0300 Subject: [PATCH 4/6] refactor(databricks): name the federation flags after the oidc token they carry databricks-token reads as a Databricks personal access token: that is what DATABRICKS_TOKEN means in Databricks' own tooling, and what a bare token field means across the connectors. This credential is an externally issued JWT, which Databricks itself calls DATABRICKS_OIDC_TOKEN, so the flags are now databricks-oidc-token and databricks-oidc-token-file. Nothing ships with the old names yet, so no deployed configuration changes. The Kubernetes example also promised a token file that nothing created: the secret pointed the token file at /var/run/secrets/databricks/token while the Deployment below mounted no such volume, so the first exchange failed unless the operator supplied the mount themselves. It now carries the matching projected serviceAccountToken volume and mount, commented out like the secret line it pairs with. Co-Authored-By: Claude Opus 5 --- README.md | 8 ++++---- config_schema.json | 12 ++++++------ docs/connector.mdx | 19 ++++++++++++++++++- pkg/config/conf.gen.go | 20 ++++++++++---------- pkg/config/config.go | 16 ++++++++-------- pkg/config/config_test.go | 10 +++++----- pkg/connector/connector.go | 6 +++--- 7 files changed, 54 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 90c630ce..6619f4a3 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ Create a [service principal federation policy](https://docs.databricks.com/aws/e on the same service principal, trusting the issuer, subject and audience of that JWT. Then supply the JWT with one of: -- `--databricks-token-file` (or `BATON_DATABRICKS_TOKEN_FILE`): a path to the +- `--databricks-oidc-token-file` (or `BATON_DATABRICKS_OIDC_TOKEN_FILE`): a path to the token. The file is read again on every token refresh, so a rotated token is picked up without restarting the connector. Prefer this for anything long-running. -- `--databricks-token` (or `BATON_DATABRICKS_TOKEN`): the JWT itself. The +- `--databricks-oidc-token` (or `BATON_DATABRICKS_OIDC_TOKEN`): the JWT itself. The connector cannot refresh it, so the sync fails once it expires. Nothing else changes: the exchange happens at the same account token endpoint, @@ -188,8 +188,8 @@ Flags: --databricks-client-id string required: The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_ID) --databricks-client-secret string The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) --databricks-exclude-workspaces strings Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces. ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) - --databricks-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires. ($BATON_DATABRICKS_TOKEN) - --databricks-token-file string Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh. ($BATON_DATABRICKS_TOKEN_FILE) + --databricks-oidc-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires. ($BATON_DATABRICKS_OIDC_TOKEN) + --databricks-oidc-token-file string Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh. ($BATON_DATABRICKS_OIDC_TOKEN_FILE) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS) diff --git a/config_schema.json b/config_schema.json index 488da4af..8ef28c18 100644 --- a/config_schema.json +++ b/config_schema.json @@ -128,14 +128,14 @@ "stringField": {} }, { - "name": "databricks-token", + "name": "databricks-oidc-token", "displayName": "Workload Identity Token", "description": "An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires.", "isSecret": true, "stringField": {} }, { - "name": "databricks-token-file", + "name": "databricks-oidc-token-file", "displayName": "Workload Identity Token File", "description": "Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh.", "stringField": {} @@ -173,16 +173,16 @@ "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", "fieldNames": [ "databricks-client-secret", - "databricks-token", - "databricks-token-file" + "databricks-oidc-token", + "databricks-oidc-token-file" ] }, { "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", "fieldNames": [ "databricks-client-secret", - "databricks-token", - "databricks-token-file" + "databricks-oidc-token", + "databricks-oidc-token-file" ] } ], diff --git a/docs/connector.mdx b/docs/connector.mdx index 3aa41f8e..cf61666e 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -238,7 +238,9 @@ stringData: # issued JWT your federation policy trusts. The connector reads the file again # on every token refresh, so a rotated token needs no restart. # Set either the client secret or one of the token options, never more than one. - # BATON_DATABRICKS_TOKEN_FILE: /var/run/secrets/databricks/token + # Nothing creates this file for you: uncomment the matching volume and + # volumeMount in the Deployment below, or mount the JWT there yourself. + # BATON_DATABRICKS_OIDC_TOKEN_FILE: /var/run/secrets/databricks/token # Optional: limit the sync to specific workspaces # (workspace name, deployment name, or numeric ID). @@ -295,6 +297,21 @@ spec: envFrom: - secretRef: name: baton-databricks-secrets + # Only for BATON_DATABRICKS_OIDC_TOKEN_FILE: mount the JWT at the path the + # secret points to. Leave this out when you use the client secret. + # volumeMounts: + # - name: databricks-oidc-token + # mountPath: /var/run/secrets/databricks + # readOnly: true + # volumes: + # - name: databricks-oidc-token + # projected: + # sources: + # - serviceAccountToken: + # path: token + # # Must match the audience of the Databricks federation policy. + # audience: + # expirationSeconds: 3600 ``` ### Step 3: Deploy the connector diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index f619ced4..b22a7c97 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -1,18 +1,18 @@ // Code generated by baton-sdk. DO NOT EDIT!!! package config -import "reflect" +import "reflect" type Databricks struct { - AccountHostname string `mapstructure:"account-hostname"` - AccountId string `mapstructure:"account-id"` - DatabricksClientId string `mapstructure:"databricks-client-id"` - DatabricksClientSecret string `mapstructure:"databricks-client-secret"` - DatabricksToken string `mapstructure:"databricks-token"` - DatabricksTokenFile string `mapstructure:"databricks-token-file"` - Hostname string `mapstructure:"hostname"` - Workspaces []string `mapstructure:"workspaces"` - BaseUrl string `mapstructure:"base-url"` + AccountHostname string `mapstructure:"account-hostname"` + AccountId string `mapstructure:"account-id"` + DatabricksClientId string `mapstructure:"databricks-client-id"` + DatabricksClientSecret string `mapstructure:"databricks-client-secret"` + DatabricksOidcToken string `mapstructure:"databricks-oidc-token"` + DatabricksOidcTokenFile string `mapstructure:"databricks-oidc-token-file"` + Hostname string `mapstructure:"hostname"` + Workspaces []string `mapstructure:"workspaces"` + BaseUrl string `mapstructure:"base-url"` DatabricksExcludeWorkspaces []string `mapstructure:"databricks-exclude-workspaces"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index 11b3caf3..0e42e955 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -23,14 +23,14 @@ var ( field.WithIsSecret(true), field.WithDisplayName("OAuth2 Client Secret"), ) - DatabricksTokenField = field.StringField( - "databricks-token", + DatabricksOIDCTokenField = field.StringField( + "databricks-oidc-token", field.WithDescription("An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires."), field.WithIsSecret(true), field.WithDisplayName("Workload Identity Token"), ) - DatabricksTokenFileField = field.StringField( - "databricks-token-file", + DatabricksOIDCTokenFileField = field.StringField( + "databricks-oidc-token-file", field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh."), field.WithDisplayName("Workload Identity Token File"), ) @@ -69,8 +69,8 @@ var ( AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, - DatabricksTokenField, - DatabricksTokenFileField, + DatabricksOIDCTokenField, + DatabricksOIDCTokenFileField, HostnameField, WorkspacesField, BaseURLField, @@ -86,7 +86,7 @@ var Config = field.NewConfiguration( field.WithIconUrl("/static/app-icons/databricks.svg"), field.WithConstraints( field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField), - field.FieldsMutuallyExclusive(DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField), - field.FieldsAtLeastOneUsed(DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField), + field.FieldsMutuallyExclusive(DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField), + field.FieldsAtLeastOneUsed(DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField), ), ) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f2966e89..24ec276b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -17,7 +17,7 @@ func TestCredentialConstraintsAreWellFormed(t *testing.T) { t.Fatalf("constraint %+v is Invalid and will never be enforced", constraint) } - if !constraintCovers(constraint, DatabricksClientSecretField, DatabricksTokenField, DatabricksTokenFileField) { + if !constraintCovers(constraint, DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField) { continue } @@ -71,11 +71,11 @@ func TestCredentialValidation(t *testing.T) { }, { name: "workload identity token alone", - cfg: Databricks{DatabricksToken: "subject-jwt"}, + cfg: Databricks{DatabricksOidcToken: "subject-jwt"}, }, { name: "workload identity token file alone", - cfg: Databricks{DatabricksTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. + cfg: Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. }, { name: "no credential", @@ -84,12 +84,12 @@ func TestCredentialValidation(t *testing.T) { }, { name: "client secret and token together", - cfg: Databricks{DatabricksClientSecret: "secret", DatabricksToken: "subject-jwt"}, + cfg: Databricks{DatabricksClientSecret: "secret", DatabricksOidcToken: "subject-jwt"}, wantErr: "mutually exclusive", }, { name: "token and token file together", - cfg: Databricks{DatabricksToken: "subject-jwt", DatabricksTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: test fixtures, not credentials. + cfg: Databricks{DatabricksOidcToken: "subject-jwt", DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: test fixtures, not credentials. wantErr: "mutually exclusive", }, } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 1f2be7d5..075f968b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -175,12 +175,12 @@ func prepareClientAuth(cfg *config.Databricks) databricks.Auth { databricksClientId := cfg.DatabricksClientId accountHostname := getAccountHostname(cfg, cfg.Hostname) - if cfg.DatabricksToken != "" || cfg.DatabricksTokenFile != "" { + if cfg.DatabricksOidcToken != "" || cfg.DatabricksOidcTokenFile != "" { return databricks.NewTokenFederation( accountID, databricksClientId, - cfg.DatabricksToken, - cfg.DatabricksTokenFile, + cfg.DatabricksOidcToken, + cfg.DatabricksOidcTokenFile, accountHostname, ) } From 804635124cd4d68090158a9f8a2ce22613af1ec0 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 3 Nov 2026 14:24:54 -0300 Subject: [PATCH 5/6] fix(databricks): offer workload identity only where it can work The exported config schema gave neither workload identity field an export target, so both reached the hosted web form, and neither works there. A hosted connector has no file system the tenant admin controls, and a pasted JWT is never refreshed: Token() asks for a fresh subject token on every exchange, so a static string returns the same expired JWT once its hour is up. The Hello check runs while that JWT is still valid, so the configuration validates and the sync breaks later. Field groups replace the mutually exclusive and at-least-one constraints. The client secret is the default and is required again, which the constraints had forced off. The token file is its own CLI-only group, so hosted no longer offers it. The static token field is removed: it works in neither mode. Validation skips any field the selected group does not list, so each group repeats the common fields; account-id is required and would otherwise stop being checked. Selecting the file method on the command line needs --auth-method, which the README now shows. The JWT lookup moves behind SubjectTokenSource, with the file as its only implementation. A source that reads from ConductorOne lands when CXE-1557 adds the minting, and Token() will not change. Verified against the tenant with a temporary federation policy carrying an inline JWKS: a locally signed JWT exchanged for a Databricks token, and the sync through it matched the client-secret sync exactly (322 resources, 279 entitlements, 789 grants). An expired JWT, a JWT signed by an untrusted key and a missing file are each refused, and restoring the file syncs again, which is the rotation the source exists for. The policy was deleted afterwards. Co-Authored-By: Claude Opus 5 --- README.md | 28 ++++-- config_schema.json | 56 +++++------ docs/connector.mdx | 4 +- pkg/config/conf.gen.go | 1 - pkg/config/config.go | 50 ++++++++-- pkg/config/config_test.go | 141 +++++++++++++++------------ pkg/connector/auth_selection_test.go | 55 +++++++++++ pkg/connector/connector.go | 20 +++- pkg/databricks/auth.go | 63 +++++++----- pkg/databricks/auth_test.go | 36 +++++-- 10 files changed, 298 insertions(+), 156 deletions(-) create mode 100644 pkg/connector/auth_selection_test.go diff --git a/README.md b/README.md index 6619f4a3..06ef90af 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,23 @@ OAuth token ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). Create a [service principal federation policy](https://docs.databricks.com/aws/en/dev-tools/auth/oauth-federation-exchange) on the same service principal, trusting the issuer, subject and audience of that -JWT. Then supply the JWT with one of: +JWT. Then select the method and point the connector at the token: -- `--databricks-oidc-token-file` (or `BATON_DATABRICKS_OIDC_TOKEN_FILE`): a path to the - token. The file is read again on every token refresh, so a rotated token is - picked up without restarting the connector. Prefer this for anything - long-running. -- `--databricks-oidc-token` (or `BATON_DATABRICKS_OIDC_TOKEN`): the JWT itself. The - connector cannot refresh it, so the sync fails once it expires. +``` +--auth-method workload-identity-file \ + --databricks-oidc-token-file /var/run/secrets/token +``` + +`--auth-method` (or `BATON_AUTH_METHOD`) is required here. Without it the +connector validates against the default method and asks for a client secret. + +The file is read again on every token refresh, so whatever rotates it controls +the lifetime and the connector needs no restart. + +This method runs the connector yourself: on the command line or in service mode. +A ConductorOne-hosted connector has no file system you control, so the option is +not offered there. Hosted support needs ConductorOne to issue the JWT, which is +tracked in CXE-1557. Nothing else changes: the exchange happens at the same account token endpoint, and the resulting token is used for both account and workspace APIs. @@ -186,10 +195,9 @@ Flags: --client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID) --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) --databricks-client-id string required: The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_ID) - --databricks-client-secret string The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) + --databricks-client-secret string required: The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API ($BATON_DATABRICKS_CLIENT_SECRET) --databricks-exclude-workspaces strings Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces. ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) - --databricks-oidc-token string An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires. ($BATON_DATABRICKS_OIDC_TOKEN) - --databricks-oidc-token-file string Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh. ($BATON_DATABRICKS_OIDC_TOKEN_FILE) + --databricks-oidc-token-file string required: Path to a file holding an externally issued JWT identifying the workload. The file is read again on every token refresh, so an external rotation is picked up without a restart. ($BATON_DATABRICKS_OIDC_TOKEN_FILE) --external-resource-c1z string The path to the c1z file to sync external baton resources with ($BATON_EXTERNAL_RESOURCE_C1Z) --external-resource-entitlement-id-filter string The entitlement that external users, groups must have access to sync external baton resources ($BATON_EXTERNAL_RESOURCE_ENTITLEMENT_ID_FILTER) --external-resource-traits strings Resource type traits (e.g. "user", "group", "app") to sync and match from the external resource c1z. When unset the matcher falls back to user and group; passing this flag replaces the full set rather than adding to it. ($BATON_EXTERNAL_RESOURCE_TRAITS) diff --git a/config_schema.json b/config_schema.json index 8ef28c18..8a5c9fa7 100644 --- a/config_schema.json +++ b/config_schema.json @@ -124,21 +124,13 @@ "name": "databricks-client-secret", "displayName": "OAuth2 Client Secret", "description": "The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API", + "isRequired": true, "isSecret": true, - "stringField": {} - }, - { - "name": "databricks-oidc-token", - "displayName": "Workload Identity Token", - "description": "An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires.", - "isSecret": true, - "stringField": {} - }, - { - "name": "databricks-oidc-token-file", - "displayName": "Workload Identity Token File", - "description": "Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh.", - "stringField": {} + "stringField": { + "rules": { + "isRequired": true + } + } }, { "name": "hostname", @@ -168,25 +160,27 @@ "workspaces", "databricks-exclude-workspaces" ] - }, - { - "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", - "fieldNames": [ - "databricks-client-secret", - "databricks-oidc-token", - "databricks-oidc-token-file" - ] - }, - { - "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", - "fieldNames": [ - "databricks-client-secret", - "databricks-oidc-token", - "databricks-oidc-token-file" - ] } ], "displayName": "Databricks", "helpUrl": "/docs/baton/databricks", - "iconUrl": "/static/app-icons/databricks.svg" + "iconUrl": "/static/app-icons/databricks.svg", + "fieldGroups": [ + { + "name": "client-secret", + "displayName": "Client Secret", + "helpText": "Authenticate with the Databricks service principal's OAuth client ID and client secret.", + "fields": [ + "account-hostname", + "account-id", + "databricks-client-id", + "hostname", + "workspaces", + "base-url", + "databricks-exclude-workspaces", + "databricks-client-secret" + ], + "default": true + } + ] } \ No newline at end of file diff --git a/docs/connector.mdx b/docs/connector.mdx index cf61666e..ebaefe56 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -37,9 +37,7 @@ The connector authenticates as an account-level service principal, named by its Exactly one of the two credentials is supplied. There is no personal access token (PAT), workspace token, or username and password option. -The token file must exist on the machine that runs the connector. A hosted connector has no file for you to rotate, so use the token file only when you run the connector yourself. - -A token pasted into **Workload Identity Token** is not refreshed. The sync fails once that JWT expires. Prefer the token file, which the connector reads again on every refresh. +Workload identity federation is available when you run the connector yourself, on the command line or in service mode. The connector reads the JWT from a file, and a ConductorOne-hosted connector has no file system you control, so the option is not offered there. Select it with `--auth-method workload-identity-file`. ## Gather Databricks credentials diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index b22a7c97..5550c7e5 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -8,7 +8,6 @@ type Databricks struct { AccountId string `mapstructure:"account-id"` DatabricksClientId string `mapstructure:"databricks-client-id"` DatabricksClientSecret string `mapstructure:"databricks-client-secret"` - DatabricksOidcToken string `mapstructure:"databricks-oidc-token"` DatabricksOidcTokenFile string `mapstructure:"databricks-oidc-token-file"` Hostname string `mapstructure:"hostname"` Workspaces []string `mapstructure:"workspaces"` diff --git a/pkg/config/config.go b/pkg/config/config.go index 0e42e955..530eb35c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,18 +21,16 @@ var ( "databricks-client-secret", field.WithDescription("The Databricks service principal's client secret used to connect to the Databricks Account and Workspace API"), field.WithIsSecret(true), + field.WithRequired(true), field.WithDisplayName("OAuth2 Client Secret"), ) - DatabricksOIDCTokenField = field.StringField( - "databricks-oidc-token", - field.WithDescription("An externally issued JWT identifying the workload, exchanged for a Databricks OAuth token. The connector does not refresh it, so the sync fails once the JWT expires."), - field.WithIsSecret(true), - field.WithDisplayName("Workload Identity Token"), - ) DatabricksOIDCTokenFileField = field.StringField( "databricks-oidc-token-file", - field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. The file must exist where the connector runs, and it is read again on every token refresh."), + field.WithDescription("Path to a file holding an externally issued JWT identifying the workload. "+ + "The file is read again on every token refresh, so an external rotation is picked up without a restart."), field.WithDisplayName("Workload Identity Token File"), + field.WithRequired(true), + field.WithExportTarget(field.ExportTargetCLIOnly), ) WorkspacesField = field.StringSliceField( "workspaces", @@ -64,12 +62,45 @@ var ( field.WithDescription("Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces."), field.WithDisplayName("Exclude Workspaces"), ) + // commonFields apply whichever way the connector authenticates. Validation + // skips any field the selected group does not list, so every group repeats + // them: account-id is required and would stop being checked otherwise. + commonFields = []field.SchemaField{ + AccountHostnameField, + AccountIdField, + DatabricksClientIdField, + HostnameField, + WorkspacesField, + BaseURLField, + ExcludeWorkspacesField, + } + + // The client secret is the default. The token file exchanges an externally + // issued JWT, and is read again on every exchange, so a rotated file keeps + // working without a restart. + fieldGroups = []field.SchemaFieldGroup{ + { + Name: "client-secret", + DisplayName: "Client Secret", + HelpText: "Authenticate with the Databricks service principal's OAuth client ID and client secret.", + Default: true, + Fields: append(append([]field.SchemaField{}, commonFields...), DatabricksClientSecretField), + }, + { + Name: "workload-identity-file", + DisplayName: "Workload Identity (token file)", + HelpText: "Exchange an externally issued JWT for a Databricks token. " + + "The JWT is read from a file on every exchange, so whatever writes that file controls the lifetime.", + ExportTarget: field.ExportTargetCLIOnly, + Fields: append(append([]field.SchemaField{}, commonFields...), DatabricksOIDCTokenFileField), + }, + } + configFields = []field.SchemaField{ AccountHostnameField, AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, - DatabricksOIDCTokenField, DatabricksOIDCTokenFileField, HostnameField, WorkspacesField, @@ -84,9 +115,8 @@ var Config = field.NewConfiguration( field.WithConnectorDisplayName("Databricks"), field.WithHelpUrl("/docs/baton/databricks"), field.WithIconUrl("/static/app-icons/databricks.svg"), + field.WithFieldGroups(fieldGroups), field.WithConstraints( field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField), - field.FieldsMutuallyExclusive(DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField), - field.FieldsAtLeastOneUsed(DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField), ), ) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 24ec276b..9cbc4969 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,90 +7,104 @@ import ( "github.com/conductorone/baton-sdk/pkg/field" ) -// An Invalid relationship makes field.Validate reject every configuration with a -// bare "invalid relationship constraint", so the connector would not start at all. -func TestCredentialConstraintsAreWellFormed(t *testing.T) { - var mutuallyExclusive, atLeastOne int - - for _, constraint := range Config.Constraints { - if constraint.Kind == field.Invalid { - t.Fatalf("constraint %+v is Invalid and will never be enforced", constraint) - } - - if !constraintCovers(constraint, DatabricksClientSecretField, DatabricksOIDCTokenField, DatabricksOIDCTokenFileField) { - continue - } - - switch constraint.Kind { - case field.MutuallyExclusive: - mutuallyExclusive++ - case field.AtLeastOne: - atLeastOne++ - case field.Invalid, field.RequiredTogether, field.Dependents: +// Validation skips every field the selected group does not list, so a group +// that forgets a required field silently stops checking it. +func TestEveryAuthGroupCarriesTheRequiredCommonFields(t *testing.T) { + for _, group := range Config.FieldGroups { + fields := group.FieldMap() + for _, required := range []field.SchemaField{AccountIdField, DatabricksClientIdField} { + if _, ok := fields[required.FieldName]; !ok { + t.Errorf("group %q omits %q, so it would no longer be validated", group.Name, required.FieldName) + } } } +} - if mutuallyExclusive != 1 { - t.Errorf("found %d mutually exclusive credential constraints, want 1", mutuallyExclusive) +func TestAuthGroupsOfferOneCredentialEach(t *testing.T) { + var defaults []string + for _, group := range Config.FieldGroups { + if group.Default { + defaults = append(defaults, group.Name) + } } - if atLeastOne != 1 { - t.Errorf("found %d at-least-one credential constraints, want 1", atLeastOne) + if len(defaults) != 1 || defaults[0] != "client-secret" { + t.Errorf("default groups = %v, want exactly [client-secret]", defaults) } -} -func constraintCovers(constraint field.SchemaFieldRelationship, want ...field.SchemaField) bool { - if len(constraint.Fields) != len(want) { - return false + for _, tt := range []struct { + group, holds, omits string + }{ + {"client-secret", DatabricksClientSecretField.FieldName, DatabricksOIDCTokenFileField.FieldName}, + {"workload-identity-file", DatabricksOIDCTokenFileField.FieldName, DatabricksClientSecretField.FieldName}, + } { + fields := Config.FieldGroupFields(tt.group) + if _, ok := fields[tt.holds]; !ok { + t.Errorf("group %q does not offer %q", tt.group, tt.holds) + } + if _, ok := fields[tt.omits]; ok { + t.Errorf("group %q also offers %q, so the two credentials are not separated", tt.group, tt.omits) + } } +} - present := make(map[string]bool, len(constraint.Fields)) - for _, f := range constraint.Fields { - present[f.FieldName] = true +// A hosted connector has no file system the tenant admin controls, so the token +// file must never reach the web form. +func TestTheTokenFileIsNotOfferedToHostedTenants(t *testing.T) { + if DatabricksOIDCTokenFileField.ExportTarget != field.ExportTargetCLIOnly { + t.Errorf("databricks-oidc-token-file ExportTarget = %q, want %q", + DatabricksOIDCTokenFileField.ExportTarget, field.ExportTargetCLIOnly) } - - for _, f := range want { - if !present[f.FieldName] { - return false + for _, group := range Config.FieldGroups { + if group.Name != "workload-identity-file" { + continue + } + if group.ExportTarget != field.ExportTargetCLIOnly { + t.Errorf("group %q ExportTarget = %q, want %q", group.Name, group.ExportTarget, field.ExportTargetCLIOnly) } } - - return true } func TestCredentialValidation(t *testing.T) { t.Parallel() tests := []struct { - name string - cfg Databricks - wantErr string + name string + authMethod string + cfg Databricks + wantErr string }{ { - name: "client secret alone", - cfg: Databricks{DatabricksClientSecret: "secret"}, + name: "client secret under its own method", + authMethod: "client-secret", + cfg: Databricks{DatabricksClientSecret: "secret"}, }, { - name: "workload identity token alone", - cfg: Databricks{DatabricksOidcToken: "subject-jwt"}, + name: "client secret missing under its own method", + authMethod: "client-secret", + cfg: Databricks{}, + wantErr: "databricks-client-secret", }, { - name: "workload identity token file alone", - cfg: Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. + name: "token file under its own method", + authMethod: "workload-identity-file", + cfg: Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. }, { - name: "no credential", - cfg: Databricks{}, - wantErr: "at least one", + name: "token file missing under its own method", + authMethod: "workload-identity-file", + cfg: Databricks{}, + wantErr: "databricks-oidc-token-file", }, { - name: "client secret and token together", - cfg: Databricks{DatabricksClientSecret: "secret", DatabricksOidcToken: "subject-jwt"}, - wantErr: "mutually exclusive", + // The secret belongs to the other group, so it is not asked for here. + name: "token file alone does not require the secret", + authMethod: "workload-identity-file", + cfg: Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a test fixture path, not a credential. }, { - name: "token and token file together", - cfg: Databricks{DatabricksOidcToken: "subject-jwt", DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: test fixtures, not credentials. - wantErr: "mutually exclusive", + name: "no method selected falls back to the default group", + cfg: Databricks{}, + wantErr: "databricks-client-secret", }, } @@ -102,23 +116,26 @@ func TestCredentialValidation(t *testing.T) { cfg.AccountId = "acc-1" cfg.DatabricksClientId = "sp-client-id" - err := field.Validate(Config, &cfg) + err := field.Validate(Config, &cfg, field.WithAuthMethod(tt.authMethod)) switch { case tt.wantErr == "" && err != nil: t.Errorf("Validate returned %v, want no error", err) case tt.wantErr != "" && err == nil: - t.Errorf("Validate succeeded, want an error containing %q", tt.wantErr) - case tt.wantErr != "" && !strings.Contains(strings.ToLower(err.Error()), tt.wantErr): - t.Errorf("Validate returned %q, want it to contain %q", err, tt.wantErr) + t.Errorf("Validate succeeded, want an error naming %q", tt.wantErr) + case tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr): + t.Errorf("Validate returned %q, want it to name %q", err, tt.wantErr) } }) } } -func TestClientSecretIsNotRequired(t *testing.T) { - if DatabricksClientSecretField.Required { - t.Error("databricks-client-secret is Required, which makes the mutually exclusive constraint Invalid") +func TestCredentialFieldRequirements(t *testing.T) { + if !DatabricksClientSecretField.Required { + t.Error("databricks-client-secret must be required: its group offers no other credential") + } + if !DatabricksOIDCTokenFileField.Required { + t.Error("databricks-oidc-token-file must be required: its group offers no other credential") } if !DatabricksClientIdField.Required { t.Error("databricks-client-id must stay required: federation is per service principal") diff --git a/pkg/connector/auth_selection_test.go b/pkg/connector/auth_selection_test.go new file mode 100644 index 00000000..16230878 --- /dev/null +++ b/pkg/connector/auth_selection_test.go @@ -0,0 +1,55 @@ +package connector + +import ( + "testing" + + "github.com/conductorone/baton-databricks/pkg/config" + "github.com/conductorone/baton-databricks/pkg/databricks" +) + +// The token file is what selects workload identity federation. The static JWT +// field is gone, so a configuration without a file authenticates with the +// client secret. +func TestPrepareClientAuthSelectsTheCredential(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.Databricks + federated bool + }{ + { + name: "client secret", + cfg: config.Databricks{DatabricksClientSecret: "secret"}, + }, + { + name: "token file", + cfg: config.Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. + federated: true, + }, + { + // The file wins: a secret left over from an earlier configuration + // must not silently take the federated deployment back to it. + name: "both", + cfg: config.Databricks{DatabricksClientSecret: "secret", DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. + federated: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := tt.cfg + cfg.AccountId = "acc-1" + cfg.DatabricksClientId = "sp-client-id" + cfg.Hostname = "cloud.databricks.com" + + auth := prepareClientAuth(&cfg) + _, federated := auth.(*databricks.TokenFederation) + if federated != tt.federated { + t.Errorf("prepareClientAuth returned %T, federated = %v, want %v", auth, federated, tt.federated) + } + }) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 075f968b..368a7f8c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -11,6 +11,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/cli" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/field" ) type Databricks struct { @@ -147,7 +148,17 @@ func New( } // NewConnector returns a new connector builder from a configuration struct. -func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { +func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { + // The auth method decides which fields are required. Validating without it + // would check the default group against a configuration built for another. + authMethod := "" + if opts != nil { + authMethod = opts.SelectedAuthMethod + } + if err := field.Validate(config.Config, cfg, field.WithAuthMethod(authMethod)); err != nil { + return nil, nil, err + } + accountHostname := getAccountHostname(cfg, cfg.Hostname) auth := prepareClientAuth(cfg) @@ -169,19 +180,18 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorO } // prepareClientAuth uses the client secret by default and switches to workload -// identity federation when one of the token fields is set. +// identity federation when a token file is configured. func prepareClientAuth(cfg *config.Databricks) databricks.Auth { accountID := cfg.AccountId databricksClientId := cfg.DatabricksClientId accountHostname := getAccountHostname(cfg, cfg.Hostname) - if cfg.DatabricksOidcToken != "" || cfg.DatabricksOidcTokenFile != "" { + if cfg.DatabricksOidcTokenFile != "" { return databricks.NewTokenFederation( accountID, databricksClientId, - cfg.DatabricksOidcToken, - cfg.DatabricksOidcTokenFile, accountHostname, + databricks.FileSubjectTokenSource{Path: cfg.DatabricksOidcTokenFile}, ) } diff --git a/pkg/databricks/auth.go b/pkg/databricks/auth.go index 89767dbe..8d4105b9 100644 --- a/pkg/databricks/auth.go +++ b/pkg/databricks/auth.go @@ -59,11 +59,37 @@ type OAuth2 struct { cfg *clientcredentials.Config } +// SubjectTokenSource yields the externally issued JWT that identifies the +// workload. Token() asks for one on every exchange, so a source that cannot +// produce a fresh token does not belong behind this interface. +type SubjectTokenSource interface { + SubjectToken() (string, error) +} + +// FileSubjectTokenSource reads the JWT from disk on every call, so whatever +// rotates the file controls the lifetime and the connector needs no restart. +type FileSubjectTokenSource struct { + Path string +} + +func (f FileSubjectTokenSource) SubjectToken() (string, error) { + raw, err := os.ReadFile(f.Path) + if err != nil { + return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("failed to read workload identity token file %q", f.Path), err) + } + + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("workload identity token file %q is empty", f.Path)) + } + + return token, nil +} + type TokenFederation struct { - clientID string - tokenURL string - token string - tokenFile string + clientID string + tokenURL string + source SubjectTokenSource } type federationTokenSource struct { @@ -94,25 +120,11 @@ func accountTokenURL(accountHostname, accountID string) string { // subjectToken returns the JWT. A file is read on every call, so a rotated token is // picked up without restarting the connector. func (t *TokenFederation) subjectToken() (string, error) { - if t.tokenFile == "" { - if t.token == "" { - return "", uhttp.WrapErrors(codes.FailedPrecondition, "no workload identity token configured") - } - - return t.token, nil - } - - raw, err := os.ReadFile(t.tokenFile) - if err != nil { - return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("failed to read workload identity token file %q", t.tokenFile), err) + if t.source == nil { + return "", uhttp.WrapErrors(codes.FailedPrecondition, "no workload identity token source configured") } - token := strings.TrimSpace(string(raw)) - if token == "" { - return "", uhttp.WrapErrors(codes.FailedPrecondition, fmt.Sprintf("workload identity token file %q is empty", t.tokenFile)) - } - - return token, nil + return t.source.SubjectToken() } // tokenExpiry never returns the zero time, which oauth2 reads as a token that @@ -187,12 +199,11 @@ func (o *OAuth2) Apply(req *http.Request) { // NewTokenFederation builds the federation auth. Exactly one of token or tokenFile // carries the JWT. -func NewTokenFederation(accID, clientID, token, tokenFile, accountHostname string) *TokenFederation { +func NewTokenFederation(accID, clientID, accountHostname string, source SubjectTokenSource) *TokenFederation { return &TokenFederation{ - clientID: clientID, - tokenURL: accountTokenURL(accountHostname, accID), - token: token, - tokenFile: tokenFile, + clientID: clientID, + tokenURL: accountTokenURL(accountHostname, accID), + source: source, } } diff --git a/pkg/databricks/auth_test.go b/pkg/databricks/auth_test.go index 2aaaf085..5f0cdb0e 100644 --- a/pkg/databricks/auth_test.go +++ b/pkg/databricks/auth_test.go @@ -44,7 +44,14 @@ func exchangeServer(t *testing.T, handler func(form url.Values) (int, string)) ( return server, received } -func newTestTokenSource(t *testing.T, tokenURL, token, tokenFile string) *federationTokenSource { +// stubSubjectTokenSource stands in for a real source so the exchange itself can +// be tested without a file. Production offers no static source: a fixed JWT +// cannot be refreshed. +type stubSubjectTokenSource string + +func (s stubSubjectTokenSource) SubjectToken() (string, error) { return string(s), nil } + +func newTestTokenSource(t *testing.T, tokenURL string, source SubjectTokenSource) *federationTokenSource { t.Helper() ctx := context.Background() @@ -55,7 +62,7 @@ func newTestTokenSource(t *testing.T, tokenURL, token, tokenFile string) *federa return &federationTokenSource{ ctx: ctx, - auth: &TokenFederation{clientID: "sp-client-id", tokenURL: tokenURL, token: token, tokenFile: tokenFile}, + auth: &TokenFederation{clientID: "sp-client-id", tokenURL: tokenURL, source: source}, httpClient: httpClient, } } @@ -76,7 +83,7 @@ func TestTokenFederationExchange(t *testing.T) { return http.StatusOK, `{"access_token":"databricks-token","token_type":"Bearer","expires_in":3600}` }) - source := newTestTokenSource(t, server.URL, "subject-jwt", "") + source := newTestTokenSource(t, server.URL, stubSubjectTokenSource("subject-jwt")) token, err := source.Token() if err != nil { @@ -115,7 +122,7 @@ func TestTokenFederationRereadsTokenFile(t *testing.T) { }) path := writeTokenFile(t, "first-jwt\n") - source := newTestTokenSource(t, server.URL, "", path) + source := newTestTokenSource(t, server.URL, FileSubjectTokenSource{Path: path}) if _, err := source.Token(); err != nil { t.Fatalf("first Token: %v", err) @@ -173,7 +180,7 @@ func TestTokenFederationExchangeErrors(t *testing.T) { return tt.status, tt.body }) - _, err := newTestTokenSource(t, server.URL, "subject-jwt", "").Token() + _, err := newTestTokenSource(t, server.URL, stubSubjectTokenSource("subject-jwt")).Token() if err == nil { t.Fatal("Token succeeded, want an error") } @@ -209,7 +216,7 @@ func TestTokenFederationExchangeErrorsAreClassified(t *testing.T) { return tt.status, `{"error":"invalid_grant","error_description":"no matching federation policy"}` }) - _, err := newTestTokenSource(t, server.URL, "subject-jwt", "").Token() + _, err := newTestTokenSource(t, server.URL, stubSubjectTokenSource("subject-jwt")).Token() if err == nil { t.Fatal("Token succeeded, want an error") } @@ -241,14 +248,13 @@ func TestTokenFederationSubjectToken(t *testing.T) { }{ {"missing file", filepath.Join(t.TempDir(), "absent.jwt"), "failed to read"}, {"empty file", writeTokenFile(t, " \n"), "is empty"}, - {"no credential at all", "", "no workload identity token configured"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := newTestTokenSource(t, "", "", tt.file).Token() + _, err := newTestTokenSource(t, "", FileSubjectTokenSource{Path: tt.file}).Token() if err == nil { t.Fatal("Token succeeded, want an error") } @@ -363,3 +369,17 @@ func TestAccountTokenURL(t *testing.T) { }) } } + +// A federation built without a source cannot answer an exchange, and it must +// say so instead of reading an empty path. +func TestTokenFederationWithoutASourceIsRefused(t *testing.T) { + t.Parallel() + + _, err := (&TokenFederation{clientID: "sp-client-id"}).subjectToken() + if err == nil { + t.Fatal("subjectToken succeeded with no source, want an error") + } + if !strings.Contains(err.Error(), "no workload identity token source configured") { + t.Errorf("error = %q, want it to name the missing source", err) + } +} From 1c442e7736645720bdcefd8f540b829ba7e33320 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 3 Nov 2026 15:01:28 -0300 Subject: [PATCH 6/6] fix(databricks): follow the selected auth method when choosing the credential prepareClientAuth switched to federation whenever a token file was set, so a file left over from another deployment replaced the credential the operator had selected, and validation did not catch it: the file is not in the client secret group, so that group never checks it. The method is the explicit choice, so the branch reads it. The group names move to constants that both the group definitions and the branch share. The Kubernetes example set BATON_DATABRICKS_OIDC_TOKEN_FILE without BATON_AUTH_METHOD, which validates against the client secret group and fails on the missing secret, so copying it produced a deployment that could not start. Both variables are now shown together. docs-info.md still described the JWT as a value or a file path, and two lines still said exactly one of two credentials is supplied. The static value is gone and the method selects the credential. Co-Authored-By: Claude Opus 5 --- docs/connector.mdx | 13 +++++--- docs/docs-info.md | 4 +-- pkg/config/config.go | 11 +++++-- pkg/connector/auth_selection_test.go | 48 ++++++++++++++++++---------- pkg/connector/connector.go | 11 ++++--- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index ebaefe56..7de21995 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -34,7 +34,7 @@ The connector authenticates as an account-level service principal, named by its - **OAuth client secret** (default): the service principal's client ID and secret. - **Workload identity federation**: the connector presents an externally issued JWT that identifies the workload, such as a SPIFFE JWT-SVID or a Kubernetes projected service account token, and Databricks exchanges it for a short-lived token. No long-lived secret is stored, and the Databricks administrator controls access through a service principal federation policy. This fits a deployment where the platform running the connector already issues it a workload identity token. -Exactly one of the two credentials is supplied. There is no personal access token (PAT), workspace token, or username and password option. +The authentication method selects the credential. There is no personal access token (PAT), workspace token, or username and password option. Workload identity federation is available when you run the connector yourself, on the command line or in service mode. The connector reads the JWT from a file, and a ConductorOne-hosted connector has no file system you control, so the option is not offered there. Select it with `--auth-method workload-identity-file`. @@ -232,12 +232,15 @@ stringData: BATON_DATABRICKS_CLIENT_SECRET: # Alternative to BATON_DATABRICKS_CLIENT_SECRET: workload identity federation. - # Point this at a projected service account token, or any other externally - # issued JWT your federation policy trusts. The connector reads the file again - # on every token refresh, so a rotated token needs no restart. - # Set either the client secret or one of the token options, never more than one. + # Both variables below are required together. Without BATON_AUTH_METHOD the + # connector validates against the client secret method and fails on the + # missing secret. + # Point the file at a projected service account token, or any other externally + # issued JWT your federation policy trusts. It is read again on every token + # refresh, so a rotated token needs no restart. # Nothing creates this file for you: uncomment the matching volume and # volumeMount in the Deployment below, or mount the JWT there yourself. + # BATON_AUTH_METHOD: workload-identity-file # BATON_DATABRICKS_OIDC_TOKEN_FILE: /var/run/secrets/databricks/token # Optional: limit the sync to specific workspaces diff --git a/docs/docs-info.md b/docs/docs-info.md index aa6dafbf..d0f198d1 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -20,9 +20,9 @@ While developing the connector, please fill out this form. This information is n > The connector requires a Databricks account ID and a service principal OAuth client ID. It syncs the account and every workspace that service principal can access. The service principal is then authenticated in one of two ways: > > - **OAuth client secret** (default): the service principal's OAuth client secret. - > - **Workload identity federation**: an externally issued JWT identifying the workload, such as a SPIFFE JWT-SVID or a Kubernetes projected service account token, supplied as a value or as a file path. The connector exchanges it for a short-lived Databricks token, so no long-lived secret is stored. This requires a service principal federation policy in Databricks. + > - **Workload identity federation**: an externally issued JWT identifying the workload, such as a SPIFFE JWT-SVID or a Kubernetes projected service account token, read from a file. The connector exchanges it for a short-lived Databricks token, so no long-lived secret is stored. This requires a service principal federation policy in Databricks. It is available only when you run the connector yourself, on the command line or in service mode, and is selected with `--auth-method workload-identity-file`. > - > Exactly one of the two credentials is supplied. + > The authentication method selects the credential. > > Google Cloud Platform and Azure Databricks customers also provide the account hostname and hostname. diff --git a/pkg/config/config.go b/pkg/config/config.go index 530eb35c..1e0aa11d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,13 @@ import ( "github.com/conductorone/baton-sdk/pkg/field" ) +// Auth method names. The connector branches on the selected one, so the group +// definitions and that branch read the same constant. +const ( + ClientSecretAuthMethod = "client-secret" + WorkloadIdentityFileAuthMethod = "workload-identity-file" +) + var ( AccountIdField = field.StringField( "account-id", @@ -80,14 +87,14 @@ var ( // working without a restart. fieldGroups = []field.SchemaFieldGroup{ { - Name: "client-secret", + Name: ClientSecretAuthMethod, DisplayName: "Client Secret", HelpText: "Authenticate with the Databricks service principal's OAuth client ID and client secret.", Default: true, Fields: append(append([]field.SchemaField{}, commonFields...), DatabricksClientSecretField), }, { - Name: "workload-identity-file", + Name: WorkloadIdentityFileAuthMethod, DisplayName: "Workload Identity (token file)", HelpText: "Exchange an externally issued JWT for a Databricks token. " + "The JWT is read from a file on every exchange, so whatever writes that file controls the lifetime.", diff --git a/pkg/connector/auth_selection_test.go b/pkg/connector/auth_selection_test.go index 16230878..dc8bfd25 100644 --- a/pkg/connector/auth_selection_test.go +++ b/pkg/connector/auth_selection_test.go @@ -7,32 +7,46 @@ import ( "github.com/conductorone/baton-databricks/pkg/databricks" ) -// The token file is what selects workload identity federation. The static JWT -// field is gone, so a configuration without a file authenticates with the -// client secret. +// The selected auth method decides the credential. A field left over from +// another deployment must not change it. func TestPrepareClientAuthSelectsTheCredential(t *testing.T) { t.Parallel() + const bothSet = "both fields set" + tests := []struct { - name string - cfg config.Databricks - federated bool + name string + authMethod string + cfg config.Databricks + federated bool }{ { - name: "client secret", - cfg: config.Databricks{DatabricksClientSecret: "secret"}, + name: "client secret", + authMethod: config.ClientSecretAuthMethod, + cfg: config.Databricks{DatabricksClientSecret: "secret"}, + }, + { + name: "token file", + authMethod: config.WorkloadIdentityFileAuthMethod, + cfg: config.Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. + federated: true, + }, + { + name: bothSet + " under the client secret method", + authMethod: config.ClientSecretAuthMethod, + cfg: config.Databricks{DatabricksClientSecret: "secret", DatabricksOidcTokenFile: "/leftover/token"}, }, { - name: "token file", - cfg: config.Databricks{DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. - federated: true, + name: bothSet + " under the token file method", + authMethod: config.WorkloadIdentityFileAuthMethod, + cfg: config.Databricks{DatabricksClientSecret: "secret", DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. + federated: true, }, { - // The file wins: a secret left over from an earlier configuration - // must not silently take the federated deployment back to it. - name: "both", - cfg: config.Databricks{DatabricksClientSecret: "secret", DatabricksOidcTokenFile: "/var/run/secrets/token"}, //nolint:gosec // G101: a fixture path, not a credential. - federated: true, + // No method selected falls back to the default group, which + // validation has already checked for a client secret. + name: "no method selected", + cfg: config.Databricks{DatabricksClientSecret: "secret", DatabricksOidcTokenFile: "/leftover/token"}, }, } @@ -45,7 +59,7 @@ func TestPrepareClientAuthSelectsTheCredential(t *testing.T) { cfg.DatabricksClientId = "sp-client-id" cfg.Hostname = "cloud.databricks.com" - auth := prepareClientAuth(&cfg) + auth := prepareClientAuth(&cfg, tt.authMethod) _, federated := auth.(*databricks.TokenFederation) if federated != tt.federated { t.Errorf("prepareClientAuth returned %T, federated = %v, want %v", auth, federated, tt.federated) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 368a7f8c..4aea4a9e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -160,7 +160,7 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect } accountHostname := getAccountHostname(cfg, cfg.Hostname) - auth := prepareClientAuth(cfg) + auth := prepareClientAuth(cfg, authMethod) cb, err := New( ctx, @@ -179,14 +179,15 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect return cb, nil, nil } -// prepareClientAuth uses the client secret by default and switches to workload -// identity federation when a token file is configured. -func prepareClientAuth(cfg *config.Databricks) databricks.Auth { +// prepareClientAuth follows the selected auth method, not whichever field +// happens to be set: a token file left over from another deployment must not +// silently replace the credential the operator chose. +func prepareClientAuth(cfg *config.Databricks, authMethod string) databricks.Auth { accountID := cfg.AccountId databricksClientId := cfg.DatabricksClientId accountHostname := getAccountHostname(cfg, cfg.Hostname) - if cfg.DatabricksOidcTokenFile != "" { + if authMethod == config.WorkloadIdentityFileAuthMethod { return databricks.NewTokenFederation( accountID, databricksClientId,