From c4270b357580647c1e5c12d1a136dde79a3b56e0 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Wed, 29 Jul 2026 16:43:55 -0500 Subject: [PATCH 01/17] CXH-2166: implement PAT (workspace token) authentication Re-add the personal-access-token auth path removed in e84a1aef so the connector matches its docs. Workspace tokens authenticate per-workspace against the Databricks Workspace API and scope the sync to the workspaces those tokens belong to; OAuth stays the default. - config: restore workspaces + workspace-tokens fields and the OAuth/token constraints; OAuth client id/secret are no longer hard-required - auth: restore TokenAuth, selecting the token by workspace host prefix so Azure dotted deployment names match correctly - connector: token-aware Validate and prepareClientAuth; thread workspaces through to the workspace builder - workspace builder: build minimal workspace resources from the configured list when the Account API is unavailable (token auth) --- config_schema.json | 59 ++++++++++++++++++++++++------ pkg/config/conf.gen.go | 2 + pkg/config/config.go | 54 ++++++++++++++++++++++++++- pkg/connector/connector.go | 73 ++++++++++++++++++++++++------------- pkg/connector/workspaces.go | 48 +++++++++++++++++++++++- pkg/databricks/auth.go | 38 +++++++++++++++++++ pkg/databricks/auth_test.go | 62 +++++++++++++++++++++++++++++++ pkg/databricks/client.go | 5 +++ 8 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 pkg/databricks/auth_test.go diff --git a/config_schema.json b/config_schema.json index 194a841b..541bada4 100644 --- a/config_schema.json +++ b/config_schema.json @@ -113,24 +113,14 @@ "name": "databricks-client-id", "displayName": "OAuth2 Client ID", "description": "The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API", - "isRequired": true, - "stringField": { - "rules": { - "isRequired": true - } - } + "stringField": {} }, { "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": "hostname", @@ -140,6 +130,19 @@ "defaultValue": "cloud.databricks.com" } }, + { + "name": "workspaces", + "displayName": "Workspaces", + "description": "Limit syncing to the specified workspaces. Required when using workspace tokens.", + "stringSliceField": {} + }, + { + "name": "workspace-tokens", + "displayName": "Workspace Tokens", + "description": "The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API", + "isSecret": true, + "stringSliceField": {} + }, { "name": "databricks-exclude-workspaces", "displayName": "Exclude Workspaces", @@ -147,6 +150,38 @@ "stringSliceField": {} } ], + "constraints": [ + { + "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", + "fieldNames": [ + "databricks-client-id", + "workspace-tokens" + ] + }, + { + "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", + "fieldNames": [ + "databricks-client-id", + "workspace-tokens" + ] + }, + { + "kind": "CONSTRAINT_KIND_REQUIRED_TOGETHER", + "fieldNames": [ + "databricks-client-id", + "databricks-client-secret" + ] + }, + { + "kind": "CONSTRAINT_KIND_DEPENDENT_ON", + "fieldNames": [ + "workspace-tokens" + ], + "secondaryFieldNames": [ + "workspaces" + ] + } + ], "displayName": "Databricks", "helpUrl": "/docs/baton/databricks", "iconUrl": "/static/app-icons/databricks.svg" diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 80ebf5b1..6ca5facf 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -9,6 +9,8 @@ type Databricks struct { DatabricksClientId string `mapstructure:"databricks-client-id"` DatabricksClientSecret string `mapstructure:"databricks-client-secret"` Hostname string `mapstructure:"hostname"` + Workspaces []string `mapstructure:"workspaces"` + WorkspaceTokens []string `mapstructure:"workspace-tokens"` BaseUrl string `mapstructure:"base-url"` DatabricksExcludeWorkspaces []string `mapstructure:"databricks-exclude-workspaces"` } diff --git a/pkg/config/config.go b/pkg/config/config.go index fb664cb3..fe1a09e2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,9 @@ package config import ( + "context" + "fmt" + "github.com/conductorone/baton-sdk/pkg/field" ) @@ -15,15 +18,24 @@ var ( "databricks-client-id", field.WithDescription("The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API"), field.WithDisplayName("OAuth2 Client ID"), - field.WithRequired(true), ) 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"), ) + WorkspacesField = field.StringSliceField( + "workspaces", + field.WithDescription("Limit syncing to the specified workspaces. Required when using workspace tokens."), + field.WithDisplayName("Workspaces"), + ) + WorkspaceTokensField = field.StringSliceField( + "workspace-tokens", + field.WithDescription("The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API"), + field.WithIsSecret(true), + field.WithDisplayName("Workspace Tokens"), + ) AccountHostnameField = field.StringField( "account-hostname", field.WithDescription("The hostname used to connect to the Databricks account API. If not set, it will be calculated from the hostname field."), @@ -52,15 +64,53 @@ var ( DatabricksClientIdField, DatabricksClientSecretField, HostnameField, + WorkspacesField, + WorkspaceTokensField, BaseURLField, ExcludeWorkspacesField, } + fieldRelationships = []field.SchemaFieldRelationship{ + field.FieldsAtLeastOneUsed( + DatabricksClientIdField, + WorkspaceTokensField, + ), + field.FieldsMutuallyExclusive( + DatabricksClientIdField, + WorkspaceTokensField, + ), + field.FieldsRequiredTogether( + DatabricksClientIdField, + DatabricksClientSecretField, + ), + field.FieldsDependentOn( + []field.SchemaField{WorkspaceTokensField}, + []field.SchemaField{WorkspacesField}, + ), + } ) //go:generate go run ./gen var Config = field.NewConfiguration( configFields, + field.WithConstraints(fieldRelationships...), field.WithConnectorDisplayName("Databricks"), field.WithHelpUrl("/docs/baton/databricks"), field.WithIconUrl("/static/app-icons/databricks.svg"), ) + +// ValidateConfig checks constraints that the field relationships can't express: a +// workspace token must be paired with the workspace it belongs to. +func ValidateConfig(ctx context.Context, cfg *Databricks) error { + workspaces := cfg.Workspaces + tokens := cfg.WorkspaceTokens + + if len(tokens) > 0 && len(workspaces) != len(tokens) { + return fmt.Errorf( + "databricks-connector: workspaces and workspace-tokens must be the same length, got %d workspaces and %d tokens", + len(workspaces), + len(tokens), + ) + } + + return nil +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 617e60a0..3ecd89bf 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -16,7 +16,8 @@ import ( ) type Databricks struct { - client *databricks.Client + client *databricks.Client + workspaces []string } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. @@ -26,7 +27,7 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newGroupBuilder(d.client), newServicePrincipalBuilder(d.client), newUserBuilder(d.client), - newWorkspaceBuilder(d.client), + newWorkspaceBuilder(d.client, d.workspaces), newRoleBuilder(d.client), } @@ -108,25 +109,39 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err isAccAPIAvailable := false isWSAPIAvailable := false - // Check if we can list users from Account API. - _, _, err := d.client.ListRoles(ctx, "", "", "") - if err == nil { - isAccAPIAvailable = true + // The Account API is unreachable with workspace tokens, so only probe it for OAuth. + if !d.client.IsTokenAuth() { + _, _, err := d.client.ListRoles(ctx, "", "", "") + if err == nil { + isAccAPIAvailable = true + } } - // Validate that credentials are valid for every workspace. - workspaces, _, err := d.client.ListWorkspaces(ctx) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) - } + // With an explicit workspace list (always the case for token auth), validate each + // configured workspace. Otherwise discover every workspace from the Account API. + if len(d.workspaces) > 0 { + for _, workspace := range d.workspaces { + _, _, err := d.client.ListRoles(ctx, workspace, "", "") + if err != nil && !isAccAPIAvailable { + return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace: %w", err) + } - for _, workspace := range workspaces { - _, _, err := d.client.ListRoles(ctx, workspace.DeploymentName, "", "") - if err != nil && !isAccAPIAvailable { - return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace.DeploymentName, err) + isWSAPIAvailable = true + } + } else { + workspaces, _, err := d.client.ListWorkspaces(ctx) + if err != nil { + return nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) } - isWSAPIAvailable = true + for _, workspace := range workspaces { + _, _, err := d.client.ListRoles(ctx, workspace.DeploymentName, "", "") + if err != nil && !isAccAPIAvailable { + return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace: %w", err) + } + + isWSAPIAvailable = true + } } // Resolve the result. @@ -148,6 +163,7 @@ func New( baseURL string, auth databricks.Auth, excludeWorkspaces []string, + workspaces []string, ) (*Databricks, error) { httpClient, err := auth.GetClient(ctx) if err != nil { @@ -160,7 +176,8 @@ func New( } return &Databricks{ - client: client, + client: client, + workspaces: workspaces, }, nil } @@ -168,6 +185,10 @@ func New( func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { l := ctxzap.Extract(ctx) + if err := config.ValidateConfig(ctx, cfg); err != nil { + return nil, nil, err + } + accountHostname := getAccountHostname(cfg, cfg.Hostname) auth := prepareClientAuth(ctx, cfg, l) @@ -179,6 +200,7 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect cfg.BaseUrl, auth, cfg.DatabricksExcludeWorkspaces, + cfg.Workspaces, ) if err != nil { l.Warn("error creating connector", zap.Error(err)) @@ -189,16 +211,17 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect } func prepareClientAuth(_ context.Context, cfg *config.Databricks, l *zap.Logger) databricks.Auth { - accountID := cfg.AccountId - databricksClientId := cfg.DatabricksClientId - databricksClientSecret := cfg.DatabricksClientSecret - accountHostname := getAccountHostname(cfg, cfg.Hostname) + if len(cfg.WorkspaceTokens) > 0 { + l.Info("using workspace token auth", zap.String("account-id", cfg.AccountId)) + return databricks.NewTokenAuth(cfg.Workspaces, cfg.WorkspaceTokens) + } + l.Info("using oauth", zap.String("account-id", cfg.AccountId)) return databricks.NewOAuth2( - accountID, - databricksClientId, - databricksClientSecret, - accountHostname, + cfg.AccountId, + cfg.DatabricksClientId, + cfg.DatabricksClientSecret, + getAccountHostname(cfg, cfg.Hostname), ) } diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 52ed5d92..57031c2a 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -24,12 +24,32 @@ const workspaceMemberEntitlement = "member" type workspaceBuilder struct { client *databricks.Client resourceType *v2.ResourceType + workspaces map[string]struct{} } func (w *workspaceBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return workspaceResourceType } +// minimalWorkspaceResource builds a workspace from just its deployment name, for +// token auth where the Account API (and its numeric workspace IDs) is unreachable. +// Users, groups and service principals hang off the workspace here instead of the account. +func minimalWorkspaceResource(_ context.Context, workspace *databricks.Workspace, parent *v2.ResourceId) (*v2.Resource, error) { + return rs.NewGroupResource( + workspace.DeploymentName, + workspaceResourceType, + workspace.DeploymentName, + nil, + rs.WithParentResourceID(parent), + rs.WithAnnotation( + &v2.ChildResourceType{ResourceTypeId: userResourceType.Id}, + &v2.ChildResourceType{ResourceTypeId: groupResourceType.Id}, + &v2.ChildResourceType{ResourceTypeId: servicePrincipalResourceType.Id}, + &v2.ChildResourceType{ResourceTypeId: roleResourceType.Id}, + ), + ) +} + func workspaceResource(_ context.Context, workspace *databricks.Workspace, parent *v2.ResourceId) (*v2.Resource, error) { profile := map[string]interface{}{ "workspace_id": workspace.ID, @@ -62,12 +82,32 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour var rv []*v2.Resource + if !w.client.IsAccountAPIAvailable() { + for workspace := range w.workspaces { + ws := &databricks.Workspace{DeploymentName: workspace} + + wr, err := minimalWorkspaceResource(ctx, ws, parentResourceID) + if err != nil { + return nil, nil, err + } + + rv = append(rv, wr) + } + + return rv, nil, nil + } + workspaces, _, err := w.client.ListWorkspaces(ctx) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) } for _, workspace := range workspaces { + // Skip workspaces outside the configured set when one was provided. + if _, ok := w.workspaces[workspace.DeploymentName]; !ok && len(w.workspaces) > 0 { + continue + } + wCopy := workspace wr, err := workspaceResource(ctx, &wCopy, parentResourceID) @@ -239,9 +279,15 @@ func (w *workspaceBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, nil } -func newWorkspaceBuilder(client *databricks.Client) *workspaceBuilder { +func newWorkspaceBuilder(client *databricks.Client, workspaces []string) *workspaceBuilder { + wMap := make(map[string]struct{}, len(workspaces)) + for _, w := range workspaces { + wMap[w] = struct{}{} + } + return &workspaceBuilder{ client: client, resourceType: workspaceResourceType, + workspaces: wMap, } } diff --git a/pkg/databricks/auth.go b/pkg/databricks/auth.go index 63cda5bc..21067cba 100644 --- a/pkg/databricks/auth.go +++ b/pkg/databricks/auth.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" @@ -29,6 +30,43 @@ func (n *NoAuth) GetClient(ctx context.Context) (*http.Client, error) { return httpClient, nil } +// TokenAuth authenticates each request with the workspace-scoped personal access +// token for the workspace it targets. Account-level requests match no token. +type TokenAuth struct { + tokens map[string]string +} + +func NewTokenAuth(workspaces, tokens []string) *TokenAuth { + tokensMap := make(map[string]string, len(workspaces)) + for i, workspace := range workspaces { + tokensMap[workspace] = tokens[i] + } + + return &TokenAuth{tokens: tokensMap} +} + +func (t *TokenAuth) Apply(req *http.Request) { + // A workspace request host is ".". Match on the + // deployment-name prefix rather than the first label, since Azure deployment + // names themselves contain a dot (e.g. "adb-1234567890.1"). + host := req.URL.Host + for workspace, token := range t.tokens { + if host == workspace || strings.HasPrefix(host, workspace+".") { + req.Header.Set("Authorization", "Bearer "+token) + return + } + } +} + +func (t *TokenAuth) GetClient(ctx context.Context) (*http.Client, error) { + httpClient, err := uhttp.NewClient(ctx, uhttp.WithLogger(true, ctxzap.Extract(ctx))) + if err != nil { + return nil, err + } + + return httpClient, nil +} + type OAuth2 struct { cfg *clientcredentials.Config } diff --git a/pkg/databricks/auth_test.go b/pkg/databricks/auth_test.go new file mode 100644 index 00000000..2d9ae80c --- /dev/null +++ b/pkg/databricks/auth_test.go @@ -0,0 +1,62 @@ +package databricks + +import ( + "net/http" + "net/url" + "testing" +) + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func TestTokenAuthApply(t *testing.T) { + auth := NewTokenAuth( + []string{"dbc-abc123", "adb-2531901403506481.1"}, + []string{"aws-token", "azure-token"}, + ) + + cases := []struct { + name string + host string + wantToken string + }{ + {"aws deployment name (no dot)", "dbc-abc123.cloud.databricks.com", "aws-token"}, + {"azure deployment name (dotted)", "adb-2531901403506481.1.azuredatabricks.net", "azure-token"}, + {"account host matches nothing", "accounts.azuredatabricks.net", ""}, + {"unknown workspace matches nothing", "dbc-other.cloud.databricks.com", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := &http.Request{URL: mustURL(t, "https://"+tc.host+"/api/2.0/preview/scim/v2/Users"), Header: http.Header{}} + auth.Apply(req) + + got := req.Header.Get("Authorization") + want := "" + if tc.wantToken != "" { + want = "Bearer " + tc.wantToken + } + if got != want { + t.Fatalf("Authorization = %q, want %q", got, want) + } + }) + } +} + +// A workspace name that prefixes another must not steal the longer one's token. +func TestTokenAuthApplyPrefixCollision(t *testing.T) { + auth := NewTokenAuth([]string{"dbc-1", "dbc-12"}, []string{"token-1", "token-12"}) + + req := &http.Request{URL: mustURL(t, "https://dbc-12.cloud.databricks.com/x"), Header: http.Header{}} + auth.Apply(req) + + if got := req.Header.Get("Authorization"); got != "Bearer token-12" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer token-12") + } +} diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index fcdd1890..ccf3dd92 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -138,6 +138,11 @@ func (c *Client) UpdateAvailability(accAPI, wsAPI bool) { c.isWSAPIAvailable = wsAPI } +func (c *Client) IsTokenAuth() bool { + _, ok := c.auth.(*TokenAuth) + return ok +} + func (c *Client) UpdateEtag(etag string) { c.etag = etag } From 5d69329f63cc070b92c2c7c87622ecd34d0ec77e Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Mon, 3 Aug 2026 06:23:42 -0500 Subject: [PATCH 02/17] CXH-2166: address review feedback on PAT auth config and logging Replace the OAuth/workspace-token field relationships with field groups so the config validation and C1 setup UI match each auth mode cleanly. Drop the two noisy info logs in prepareClientAuth to debug level, guard NewTokenAuth against a shorter tokens slice than workspaces, and add test coverage for both. --- README.md | 5 +++ config_schema.json | 81 +++++++++++++++++++++---------------- pkg/config/config.go | 43 +++++++++++--------- pkg/config/config_test.go | 33 +++++++++++++++ pkg/connector/connector.go | 4 +- pkg/databricks/auth.go | 3 ++ pkg/databricks/auth_test.go | 12 ++++++ 7 files changed, 126 insertions(+), 55 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/README.md b/README.md index 289d0208..150b76d5 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,11 @@ both flags at the same time. If you do that, connector will sync with all workspaces that are associated with provided tokens and all workspaces that are in the list of workspaces. +When authenticating with `--workspace-tokens` instead of the OAuth client ID and +secret, also pass `--auth-method workspace-token` (or set +`BATON_AUTH_METHOD=workspace-token`), otherwise the connector validates against +the OAuth fields by default and rejects the config. + To instead exclude specific workspaces from the sync, pass them to the `--databricks-exclude-workspaces` flag (or the `BATON_DATABRICKS_EXCLUDE_WORKSPACES` environment variable) as a comma-separated diff --git a/config_schema.json b/config_schema.json index 541bada4..b9e53a32 100644 --- a/config_schema.json +++ b/config_schema.json @@ -113,14 +113,24 @@ "name": "databricks-client-id", "displayName": "OAuth2 Client ID", "description": "The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API", - "stringField": {} + "isRequired": true, + "stringField": { + "rules": { + "isRequired": true + } + } }, { "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": {} + "stringField": { + "rules": { + "isRequired": true + } + } }, { "name": "hostname", @@ -140,8 +150,13 @@ "name": "workspace-tokens", "displayName": "Workspace Tokens", "description": "The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API", + "isRequired": true, "isSecret": true, - "stringSliceField": {} + "stringSliceField": { + "rules": { + "isRequired": true + } + } }, { "name": "databricks-exclude-workspaces", @@ -150,39 +165,37 @@ "stringSliceField": {} } ], - "constraints": [ - { - "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", - "fieldNames": [ - "databricks-client-id", - "workspace-tokens" - ] - }, - { - "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", - "fieldNames": [ - "databricks-client-id", - "workspace-tokens" - ] - }, - { - "kind": "CONSTRAINT_KIND_REQUIRED_TOGETHER", - "fieldNames": [ + "displayName": "Databricks", + "helpUrl": "/docs/baton/databricks", + "iconUrl": "/static/app-icons/databricks.svg", + "fieldGroups": [ + { + "name": "oauth2", + "displayName": "OAuth2", + "helpText": "Authenticate as a service principal using an OAuth2 client ID and secret.", + "fields": [ + "account-id", "databricks-client-id", - "databricks-client-secret" - ] - }, - { - "kind": "CONSTRAINT_KIND_DEPENDENT_ON", - "fieldNames": [ - "workspace-tokens" + "databricks-client-secret", + "hostname", + "account-hostname", + "workspaces", + "base-url" ], - "secondaryFieldNames": [ - "workspaces" + "default": true + }, + { + "name": "workspace-token", + "displayName": "Workspace token", + "helpText": "Authenticate with a personal access token scoped to each workspace.", + "fields": [ + "account-id", + "workspaces", + "workspace-tokens", + "hostname", + "account-hostname", + "base-url" ] } - ], - "displayName": "Databricks", - "helpUrl": "/docs/baton/databricks", - "iconUrl": "/static/app-icons/databricks.svg" + ] } \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index fe1a09e2..40ade1fb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,11 @@ import ( "github.com/conductorone/baton-sdk/pkg/field" ) +const ( + DatabricksOAuth2Group = "oauth2" + DatabricksWorkspaceTokenGroup = "workspace-token" +) + var ( AccountIdField = field.StringField( "account-id", @@ -17,12 +22,14 @@ var ( DatabricksClientIdField = field.StringField( "databricks-client-id", field.WithDescription("The Databricks service principal's client ID used to connect to the Databricks Account and Workspace API"), + field.WithRequired(true), field.WithDisplayName("OAuth2 Client ID"), ) 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"), ) WorkspacesField = field.StringSliceField( @@ -34,6 +41,7 @@ var ( "workspace-tokens", field.WithDescription("The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API"), field.WithIsSecret(true), + field.WithRequired(true), field.WithDisplayName("Workspace Tokens"), ) AccountHostnameField = field.StringField( @@ -69,33 +77,30 @@ var ( BaseURLField, ExcludeWorkspacesField, } - fieldRelationships = []field.SchemaFieldRelationship{ - field.FieldsAtLeastOneUsed( - DatabricksClientIdField, - WorkspaceTokensField, - ), - field.FieldsMutuallyExclusive( - DatabricksClientIdField, - WorkspaceTokensField, - ), - field.FieldsRequiredTogether( - DatabricksClientIdField, - DatabricksClientSecretField, - ), - field.FieldsDependentOn( - []field.SchemaField{WorkspaceTokensField}, - []field.SchemaField{WorkspacesField}, - ), - } ) //go:generate go run ./gen var Config = field.NewConfiguration( configFields, - field.WithConstraints(fieldRelationships...), field.WithConnectorDisplayName("Databricks"), field.WithHelpUrl("/docs/baton/databricks"), field.WithIconUrl("/static/app-icons/databricks.svg"), + field.WithFieldGroups([]field.SchemaFieldGroup{ + { + Name: DatabricksOAuth2Group, + DisplayName: "OAuth2", + HelpText: "Authenticate as a service principal using an OAuth2 client ID and secret.", + Fields: []field.SchemaField{AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, HostnameField, AccountHostnameField, WorkspacesField, BaseURLField}, + Default: true, + }, + { + Name: DatabricksWorkspaceTokenGroup, + DisplayName: "Workspace token", + HelpText: "Authenticate with a personal access token scoped to each workspace.", + Fields: []field.SchemaField{AccountIdField, WorkspacesField, WorkspaceTokensField, HostnameField, AccountHostnameField, BaseURLField}, + Default: false, + }, + }), ) // ValidateConfig checks constraints that the field relationships can't express: a diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..2ba4a3f3 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "context" + "testing" +) + +func TestValidateConfig(t *testing.T) { + cases := []struct { + name string + workspaces []string + tokens []string + wantErr bool + }{ + {"no tokens", nil, nil, false}, + {"equal length", []string{"ws-1", "ws-2"}, []string{"tok-1", "tok-2"}, false}, + {"more workspaces than tokens", []string{"ws-1", "ws-2"}, []string{"tok-1"}, true}, + {"tokens without workspaces", nil, []string{"tok-1"}, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &Databricks{Workspaces: tc.workspaces, WorkspaceTokens: tc.tokens} + err := ValidateConfig(context.Background(), cfg) + if tc.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 3ecd89bf..54fdaf92 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -212,11 +212,11 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect func prepareClientAuth(_ context.Context, cfg *config.Databricks, l *zap.Logger) databricks.Auth { if len(cfg.WorkspaceTokens) > 0 { - l.Info("using workspace token auth", zap.String("account-id", cfg.AccountId)) + l.Debug("using workspace token auth", zap.String("account-id", cfg.AccountId)) return databricks.NewTokenAuth(cfg.Workspaces, cfg.WorkspaceTokens) } - l.Info("using oauth", zap.String("account-id", cfg.AccountId)) + l.Debug("using oauth", zap.String("account-id", cfg.AccountId)) return databricks.NewOAuth2( cfg.AccountId, cfg.DatabricksClientId, diff --git a/pkg/databricks/auth.go b/pkg/databricks/auth.go index 21067cba..5faec31b 100644 --- a/pkg/databricks/auth.go +++ b/pkg/databricks/auth.go @@ -39,6 +39,9 @@ type TokenAuth struct { func NewTokenAuth(workspaces, tokens []string) *TokenAuth { tokensMap := make(map[string]string, len(workspaces)) for i, workspace := range workspaces { + if i >= len(tokens) { + break + } tokensMap[workspace] = tokens[i] } diff --git a/pkg/databricks/auth_test.go b/pkg/databricks/auth_test.go index 2d9ae80c..7a319988 100644 --- a/pkg/databricks/auth_test.go +++ b/pkg/databricks/auth_test.go @@ -60,3 +60,15 @@ func TestTokenAuthApplyPrefixCollision(t *testing.T) { t.Fatalf("Authorization = %q, want %q", got, "Bearer token-12") } } + +// Fewer tokens than workspaces must not panic; unmatched workspaces just get no token. +func TestNewTokenAuthFewerTokensThanWorkspaces(t *testing.T) { + auth := NewTokenAuth([]string{"dbc-1", "dbc-2"}, []string{"token-1"}) + + req := &http.Request{URL: mustURL(t, "https://dbc-2.cloud.databricks.com/x"), Header: http.Header{}} + auth.Apply(req) + + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization = %q, want empty", got) + } +} From 850b2a60c3af6a196a0c386edba3a80e2a5aa50e Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Mon, 3 Aug 2026 06:25:23 -0500 Subject: [PATCH 03/17] CXH-2166: restore mutual exclusivity between OAuth and workspace tokens Field groups only validate the selected auth-method's fields, so setting both databricks-client-id and workspace-tokens together no longer failed validation the way the old field relationships did. Add the check back in ValidateConfig, which always runs regardless of which group is selected. --- pkg/config/config.go | 9 +++++++-- pkg/config/config_test.go | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 40ade1fb..5cf9a5f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -103,12 +103,17 @@ var Config = field.NewConfiguration( }), ) -// ValidateConfig checks constraints that the field relationships can't express: a -// workspace token must be paired with the workspace it belongs to. +// ValidateConfig checks constraints that field groups can't express: OAuth2 and +// workspace-token credentials are mutually exclusive, and a workspace token +// must be paired with the workspace it belongs to. func ValidateConfig(ctx context.Context, cfg *Databricks) error { workspaces := cfg.Workspaces tokens := cfg.WorkspaceTokens + if len(tokens) > 0 && cfg.DatabricksClientId != "" { + return fmt.Errorf("databricks-connector: databricks-client-id and workspace-tokens are mutually exclusive") + } + if len(tokens) > 0 && len(workspaces) != len(tokens) { return fmt.Errorf( "databricks-connector: workspaces and workspace-tokens must be the same length, got %d workspaces and %d tokens", diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2ba4a3f3..3e55ff12 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -31,3 +31,17 @@ func TestValidateConfig(t *testing.T) { }) } } + +// Both auth modes' fields live in the same struct; ValidateConfig must reject +// them being set together since field groups only validate one selected group. +func TestValidateConfigRejectsBothAuthModes(t *testing.T) { + cfg := &Databricks{ + DatabricksClientId: "client-id", + Workspaces: []string{"ws-1"}, + WorkspaceTokens: []string{"tok-1"}, + } + + if err := ValidateConfig(context.Background(), cfg); err == nil { + t.Fatal("expected error, got nil") + } +} From c08d8b1f597310afd9e1ff81007350dab7a6b2d5 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Wed, 5 Aug 2026 15:44:13 -0500 Subject: [PATCH 04/17] CXH-2166: pick auth mode from opts.SelectedAuthMethod, not field presence --- pkg/config/config.go | 16 +++++++--------- pkg/config/config_test.go | 17 ++++++++++------- pkg/connector/connector.go | 13 +++++++++---- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5cf9a5f5..9c2c1a83 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -105,20 +105,18 @@ var Config = field.NewConfiguration( // ValidateConfig checks constraints that field groups can't express: OAuth2 and // workspace-token credentials are mutually exclusive, and a workspace token -// must be paired with the workspace it belongs to. -func ValidateConfig(ctx context.Context, cfg *Databricks) error { - workspaces := cfg.Workspaces - tokens := cfg.WorkspaceTokens - - if len(tokens) > 0 && cfg.DatabricksClientId != "" { +// must be paired with the workspace it belongs to when workspace-token auth +// is the selected auth method. +func ValidateConfig(ctx context.Context, cfg *Databricks, authMethod string) error { + if len(cfg.WorkspaceTokens) > 0 && cfg.DatabricksClientId != "" { return fmt.Errorf("databricks-connector: databricks-client-id and workspace-tokens are mutually exclusive") } - if len(tokens) > 0 && len(workspaces) != len(tokens) { + if authMethod == DatabricksWorkspaceTokenGroup && len(cfg.Workspaces) != len(cfg.WorkspaceTokens) { return fmt.Errorf( "databricks-connector: workspaces and workspace-tokens must be the same length, got %d workspaces and %d tokens", - len(workspaces), - len(tokens), + len(cfg.Workspaces), + len(cfg.WorkspaceTokens), ) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3e55ff12..e9903578 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -10,18 +10,20 @@ func TestValidateConfig(t *testing.T) { name string workspaces []string tokens []string + authMethod string wantErr bool }{ - {"no tokens", nil, nil, false}, - {"equal length", []string{"ws-1", "ws-2"}, []string{"tok-1", "tok-2"}, false}, - {"more workspaces than tokens", []string{"ws-1", "ws-2"}, []string{"tok-1"}, true}, - {"tokens without workspaces", nil, []string{"tok-1"}, true}, + {"no tokens", nil, nil, DatabricksWorkspaceTokenGroup, false}, + {"equal length", []string{"ws-1", "ws-2"}, []string{"tok-1", "tok-2"}, DatabricksWorkspaceTokenGroup, false}, + {"more workspaces than tokens", []string{"ws-1", "ws-2"}, []string{"tok-1"}, DatabricksWorkspaceTokenGroup, true}, + {"tokens without workspaces", nil, []string{"tok-1"}, DatabricksWorkspaceTokenGroup, true}, + {"mismatched lengths ignored outside workspace-token method", []string{"ws-1", "ws-2"}, []string{"tok-1"}, DatabricksOAuth2Group, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := &Databricks{Workspaces: tc.workspaces, WorkspaceTokens: tc.tokens} - err := ValidateConfig(context.Background(), cfg) + err := ValidateConfig(context.Background(), cfg, tc.authMethod) if tc.wantErr && err == nil { t.Fatal("expected error, got nil") } @@ -33,7 +35,8 @@ func TestValidateConfig(t *testing.T) { } // Both auth modes' fields live in the same struct; ValidateConfig must reject -// them being set together since field groups only validate one selected group. +// them being set together regardless of the selected auth method, since field +// groups only validate the fields in the one selected group. func TestValidateConfigRejectsBothAuthModes(t *testing.T) { cfg := &Databricks{ DatabricksClientId: "client-id", @@ -41,7 +44,7 @@ func TestValidateConfigRejectsBothAuthModes(t *testing.T) { WorkspaceTokens: []string{"tok-1"}, } - if err := ValidateConfig(context.Background(), cfg); err == nil { + if err := ValidateConfig(context.Background(), cfg, DatabricksOAuth2Group); err == nil { t.Fatal("expected error, got nil") } } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 54fdaf92..d9fff5fd 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -185,12 +185,17 @@ func New( func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { l := ctxzap.Extract(ctx) - if err := config.ValidateConfig(ctx, cfg); err != nil { + authMethod := "" + if opts != nil { + authMethod = opts.SelectedAuthMethod + } + + if err := config.ValidateConfig(ctx, cfg, authMethod); err != nil { return nil, nil, err } accountHostname := getAccountHostname(cfg, cfg.Hostname) - auth := prepareClientAuth(ctx, cfg, l) + auth := prepareClientAuth(ctx, cfg, authMethod, l) cb, err := New( ctx, @@ -210,8 +215,8 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect return cb, nil, nil } -func prepareClientAuth(_ context.Context, cfg *config.Databricks, l *zap.Logger) databricks.Auth { - if len(cfg.WorkspaceTokens) > 0 { +func prepareClientAuth(_ context.Context, cfg *config.Databricks, authMethod string, l *zap.Logger) databricks.Auth { + if authMethod == config.DatabricksWorkspaceTokenGroup { l.Debug("using workspace token auth", zap.String("account-id", cfg.AccountId)) return databricks.NewTokenAuth(cfg.Workspaces, cfg.WorkspaceTokens) } From 870c90ce8918398168f759c19bab5f64dc179c6c Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 6 Aug 2026 15:08:37 -0500 Subject: [PATCH 05/17] docs: refresh CLI usage dump in README after rebase Reflects the additional baton-sdk flags picked up by rebasing onto main. --- README.md | 54 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 150b76d5..cb710faa 100644 --- a/README.md +++ b/README.md @@ -134,25 +134,47 @@ Usage: Available Commands: capabilities Get connector capabilities completion Generate the autocompletion script for the specified shell + config Get the connector config schema + health-check Check the health of a running connector help Help about any command Flags: - --account-hostname string The hostname used to connect to the Databricks account API. If not set, it will be calculated from the hostname field. ($BATON_ACCOUNT_HOSTNAME) - --account-id string required: The Databricks account ID used to connect to the Databricks Account and Workspace API ($BATON_ACCOUNT_ID) - --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 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 ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) - -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") - -h, --help help for baton-databricks - --hostname string The Databricks hostname used to connect to the Databricks API ($BATON_HOSTNAME) (default "cloud.databricks.com") - --log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json") - --log-level string The log level: debug, info, warn, error ($BATON_LOG_LEVEL) (default "info") - -p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING) - --skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC) - --ticketing This must be set to enable ticketing support ($BATON_TICKETING) - -v, --version version for baton-databricks + --account-hostname string The hostname used to connect to the Databricks account API. If not set, it will be calculated from the hostname field. ($BATON_ACCOUNT_HOSTNAME) + --account-id string required: The Databricks account ID used to connect to the Databricks Account and Workspace API ($BATON_ACCOUNT_ID) + --auth-method string ($BATON_AUTH_METHOD) + --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-exclude-workspaces strings Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID ($BATON_DATABRICKS_EXCLUDE_WORKSPACES) + --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) + -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") + --health-check Enable the HTTP health check endpoint ($BATON_HEALTH_CHECK) + --health-check-port int Port for the HTTP health check endpoint ($BATON_HEALTH_CHECK_PORT) (default 8081) + -h, --help help for baton-databricks + --hostname string The Databricks hostname used to connect to the Databricks API ($BATON_HOSTNAME) (default "cloud.databricks.com") + --http-timeout-seconds int HTTP client timeout in seconds (max 1800) ($BATON_HTTP_TIMEOUT_SECONDS) (default 300) + --keep-previous-sync-c1z Keep the previously synced c1z on disk to enable ETag replay across service-mode syncs (requires a connector that supports ETag replay; costs one c1z of local disk) ($BATON_KEEP_PREVIOUS_SYNC_C1Z) + --log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json") + --log-level string The log level: debug, info, warn, error ($BATON_LOG_LEVEL) (default "info") + --log-level-debug-expires-at string The timestamp indicating when debug-level logging should expire ($BATON_LOG_LEVEL_DEBUG_EXPIRES_AT) + --log-path strings The file path to write logs to ($BATON_LOG_PATH) + --otel-collector-endpoint string The endpoint of the OpenTelemetry collector to send observability data to (used for both tracing and logging if specific endpoints are not provided) ($BATON_OTEL_COLLECTOR_ENDPOINT) + --parallel-sync Deprecated: use --workers instead. ($BATON_PARALLEL_SYNC) + -p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING) + --skip-entitlements-and-grants This must be set to skip syncing of entitlements and grants ($BATON_SKIP_ENTITLEMENTS_AND_GRANTS) + --skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC) + --storage-engine string The storage engine to use when opening the sync c1z file: sqlite or pebble. Leave unset to use the baton-sdk default. ($BATON_STORAGE_ENGINE) + --sync-resource-types strings The resource type IDs to sync ($BATON_SYNC_RESOURCE_TYPES) + --sync-resources strings The resource IDs to sync ($BATON_SYNC_RESOURCES) + --task-concurrency int The number of Baton tasks to run concurrently in service mode. Tasks may include sync, grant, revoke, and more. Minimum value is 1, maximum value is 100. ($BATON_TASK_CONCURRENCY) (default 3) + --ticketing This must be set to enable ticketing support ($BATON_TICKETING) + -v, --version version for baton-databricks + --workers int The number of sync workers to use. -1 for auto-detect, 0 for sequential, >0 for parallel ($BATON_WORKERS) + --workspace-tokens strings required: The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API ($BATON_WORKSPACE_TOKENS) + --workspaces strings Limit syncing to the specified workspaces. Required when using workspace tokens. ($BATON_WORKSPACES) Use "baton-databricks [command] --help" for more information about a command. ``` From 8b12e6aea49fe3ec9e25be6d740bfdd599a534dc Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Wed, 12 Aug 2026 14:13:22 -0500 Subject: [PATCH 06/17] CXH-2166: fix longest-prefix token match and workspace field docs --- docs/connector.mdx | 4 ++-- pkg/config/config.go | 2 +- pkg/databricks/auth.go | 18 ++++++++++++------ pkg/databricks/auth_test.go | 13 +++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 88ce529e..89dfaae1 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -83,7 +83,7 @@ OR - Account ID - Personal access token -- Workspace ID for the Databricks workspace you're syncing +- Deployment name of the Databricks workspace you're syncing (the subdomain in the workspace URL, not the workspace ID) OR @@ -227,7 +227,7 @@ stringData: # Databricks credentials, option 2 BATON_ACCOUNT_ID: BATON_WORKSPACE_TOKENS: - BATON_WORKSPACES: + BATON_WORKSPACES: # Databricks credentials, option 3 BATON_ACCOUNT_ID: diff --git a/pkg/config/config.go b/pkg/config/config.go index 9c2c1a83..4701049b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,7 +34,7 @@ var ( ) WorkspacesField = field.StringSliceField( "workspaces", - field.WithDescription("Limit syncing to the specified workspaces. Required when using workspace tokens."), + field.WithDescription("Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens."), field.WithDisplayName("Workspaces"), ) WorkspaceTokensField = field.StringSliceField( diff --git a/pkg/databricks/auth.go b/pkg/databricks/auth.go index 5faec31b..1ef846cf 100644 --- a/pkg/databricks/auth.go +++ b/pkg/databricks/auth.go @@ -49,15 +49,21 @@ func NewTokenAuth(workspaces, tokens []string) *TokenAuth { } func (t *TokenAuth) Apply(req *http.Request) { - // A workspace request host is ".". Match on the - // deployment-name prefix rather than the first label, since Azure deployment - // names themselves contain a dot (e.g. "adb-1234567890.1"). + // A workspace request host is ".". A shorter + // deployment name can be a false prefix of a longer one (Azure names + // contain a dot, e.g. "adb-123" of "adb-123.1"), so match the longest one. host := req.URL.Host + var bestWorkspace, bestToken string for workspace, token := range t.tokens { - if host == workspace || strings.HasPrefix(host, workspace+".") { - req.Header.Set("Authorization", "Bearer "+token) - return + if host != workspace && !strings.HasPrefix(host, workspace+".") { + continue } + if len(workspace) > len(bestWorkspace) { + bestWorkspace, bestToken = workspace, token + } + } + if bestToken != "" { + req.Header.Set("Authorization", "Bearer "+bestToken) } } diff --git a/pkg/databricks/auth_test.go b/pkg/databricks/auth_test.go index 7a319988..3c19b8d1 100644 --- a/pkg/databricks/auth_test.go +++ b/pkg/databricks/auth_test.go @@ -61,6 +61,19 @@ func TestTokenAuthApplyPrefixCollision(t *testing.T) { } } +// An Azure deployment name's own dot must not let a shorter workspace name +// falsely prefix a longer one that embeds it (e.g. "adb-123" of "adb-123.1"). +func TestTokenAuthApplyNestedDottedPrefix(t *testing.T) { + auth := NewTokenAuth([]string{"adb-123", "adb-123.1"}, []string{"token-short", "token-long"}) + + req := &http.Request{URL: mustURL(t, "https://adb-123.1.azuredatabricks.net/x"), Header: http.Header{}} + auth.Apply(req) + + if got := req.Header.Get("Authorization"); got != "Bearer token-long" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer token-long") + } +} + // Fewer tokens than workspaces must not panic; unmatched workspaces just get no token. func TestNewTokenAuthFewerTokensThanWorkspaces(t *testing.T) { auth := NewTokenAuth([]string{"dbc-1", "dbc-2"}, []string{"token-1"}) From df53744b8e3dac859e4a72029d689eb12e704330 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Wed, 12 Aug 2026 14:32:48 -0500 Subject: [PATCH 07/17] CXH-2166: fix workspace listing for OAuth installs, address remaining review suggestions List() keyed the token-auth path off account-API availability instead of auth type, so an OAuth install whose account-level probe failed (but whose workspace API worked) silently synced zero workspaces. Also applies databricks-exclude-workspaces to the token-auth path, makes the --workspaces filter case-insensitive with a warning on no match, exposes databricks-exclude-workspaces in both auth field groups, restores the workspace name dropped from two validation error messages, and broadens the OAuth/token mutual-exclusion check to cover databricks-client-secret. --- docs/connector.mdx | 1 + pkg/config/config.go | 8 ++++---- pkg/config/config_test.go | 12 ++++++++++++ pkg/connector/connector.go | 4 ++-- pkg/connector/workspaces.go | 38 ++++++++++++++++++++++++++++++++++--- pkg/databricks/client.go | 7 +++++++ 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 89dfaae1..0eee30b9 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -226,6 +226,7 @@ stringData: # Databricks credentials, option 2 BATON_ACCOUNT_ID: + BATON_AUTH_METHOD: workspace-token BATON_WORKSPACE_TOKENS: BATON_WORKSPACES: diff --git a/pkg/config/config.go b/pkg/config/config.go index 4701049b..bddd7a5c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -90,14 +90,14 @@ var Config = field.NewConfiguration( Name: DatabricksOAuth2Group, DisplayName: "OAuth2", HelpText: "Authenticate as a service principal using an OAuth2 client ID and secret.", - Fields: []field.SchemaField{AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, HostnameField, AccountHostnameField, WorkspacesField, BaseURLField}, + Fields: []field.SchemaField{AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, HostnameField, AccountHostnameField, WorkspacesField, BaseURLField, ExcludeWorkspacesField}, Default: true, }, { Name: DatabricksWorkspaceTokenGroup, DisplayName: "Workspace token", HelpText: "Authenticate with a personal access token scoped to each workspace.", - Fields: []field.SchemaField{AccountIdField, WorkspacesField, WorkspaceTokensField, HostnameField, AccountHostnameField, BaseURLField}, + Fields: []field.SchemaField{AccountIdField, WorkspacesField, WorkspaceTokensField, HostnameField, AccountHostnameField, BaseURLField, ExcludeWorkspacesField}, Default: false, }, }), @@ -108,8 +108,8 @@ var Config = field.NewConfiguration( // must be paired with the workspace it belongs to when workspace-token auth // is the selected auth method. func ValidateConfig(ctx context.Context, cfg *Databricks, authMethod string) error { - if len(cfg.WorkspaceTokens) > 0 && cfg.DatabricksClientId != "" { - return fmt.Errorf("databricks-connector: databricks-client-id and workspace-tokens are mutually exclusive") + if len(cfg.WorkspaceTokens) > 0 && (cfg.DatabricksClientId != "" || cfg.DatabricksClientSecret != "") { + return fmt.Errorf("databricks-connector: databricks-client-id/databricks-client-secret and workspace-tokens are mutually exclusive") } if authMethod == DatabricksWorkspaceTokenGroup && len(cfg.Workspaces) != len(cfg.WorkspaceTokens) { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e9903578..0e3ed635 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -48,3 +48,15 @@ func TestValidateConfigRejectsBothAuthModes(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestValidateConfigRejectsClientSecretWithTokens(t *testing.T) { + cfg := &Databricks{ + DatabricksClientSecret: "client-secret", + Workspaces: []string{"ws-1"}, + WorkspaceTokens: []string{"tok-1"}, + } + + if err := ValidateConfig(context.Background(), cfg, DatabricksOAuth2Group); err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index d9fff5fd..79f21b92 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -123,7 +123,7 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err for _, workspace := range d.workspaces { _, _, err := d.client.ListRoles(ctx, workspace, "", "") if err != nil && !isAccAPIAvailable { - return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace, err) } isWSAPIAvailable = true @@ -137,7 +137,7 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err for _, workspace := range workspaces { _, _, err := d.client.ListRoles(ctx, workspace.DeploymentName, "", "") if err != nil && !isAccAPIAvailable { - return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace.DeploymentName, err) } isWSAPIAvailable = true diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 57031c2a..b26a886c 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -82,8 +82,12 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour var rv []*v2.Resource - if !w.client.IsAccountAPIAvailable() { + if w.client.IsTokenAuth() { for workspace := range w.workspaces { + if w.client.IsWorkspaceExcluded(workspace) { + continue + } + ws := &databricks.Workspace{DeploymentName: workspace} wr, err := minimalWorkspaceResource(ctx, ws, parentResourceID) @@ -102,10 +106,15 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour return nil, nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) } + matchedConfigured := make(map[string]struct{}, len(w.workspaces)) for _, workspace := range workspaces { // Skip workspaces outside the configured set when one was provided. - if _, ok := w.workspaces[workspace.DeploymentName]; !ok && len(w.workspaces) > 0 { - continue + if len(w.workspaces) > 0 { + cfg, ok := matchConfiguredWorkspace(w.workspaces, workspace.DeploymentName) + if !ok { + continue + } + matchedConfigured[cfg] = struct{}{} } wCopy := workspace @@ -118,9 +127,32 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour rv = append(rv, wr) } + l := ctxzap.Extract(ctx) + for workspace := range w.workspaces { + if _, ok := matchedConfigured[workspace]; !ok { + l.Warn("databricks-connector: configured workspace not found among account workspaces", + zap.String("workspace", workspace), + ) + } + } + return rv, nil, nil } +// matchConfiguredWorkspace looks up deploymentName case-insensitively, returning +// the matched key so warnings can report the value the user configured. +func matchConfiguredWorkspace(configured map[string]struct{}, deploymentName string) (string, bool) { + if _, ok := configured[deploymentName]; ok { + return deploymentName, true + } + for cfg := range configured { + if strings.EqualFold(cfg, deploymentName) { + return cfg, true + } + } + return "", false +} + // Entitlements returns slice of entitlements representing workspace members. // To get workspace members, we can only use the account API. func (w *workspaceBuilder) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index ccf3dd92..0d3a7ce6 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -118,6 +118,13 @@ func (c *Client) isWorkspaceExcluded(w Workspace) ([]string, bool) { return keys, len(keys) > 0 } +// IsWorkspaceExcluded reports whether deploymentName matches the +// databricks-exclude-workspaces set. +func (c *Client) IsWorkspaceExcluded(deploymentName string) bool { + _, ok := c.isWorkspaceExcluded(Workspace{DeploymentName: deploymentName}) + return ok +} + func (c *Client) workspaceUrl(workspaceId string) *url.URL { return &url.URL{ Scheme: "https", From 27f217d7f63d774c8347a56eaeb6399a53392339 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Wed, 12 Aug 2026 14:38:15 -0500 Subject: [PATCH 08/17] CXH-2166: wrap long field-group Fields slice to satisfy line-length lint --- pkg/config/config.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index bddd7a5c..571728f2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -90,8 +90,11 @@ var Config = field.NewConfiguration( Name: DatabricksOAuth2Group, DisplayName: "OAuth2", HelpText: "Authenticate as a service principal using an OAuth2 client ID and secret.", - Fields: []field.SchemaField{AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, HostnameField, AccountHostnameField, WorkspacesField, BaseURLField, ExcludeWorkspacesField}, - Default: true, + Fields: []field.SchemaField{ + AccountIdField, DatabricksClientIdField, DatabricksClientSecretField, + HostnameField, AccountHostnameField, WorkspacesField, BaseURLField, ExcludeWorkspacesField, + }, + Default: true, }, { Name: DatabricksWorkspaceTokenGroup, From c29d543e518048687252464ff0d0070613afe22d Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 13 Aug 2026 15:24:43 -0500 Subject: [PATCH 09/17] CXH-2166: address Warn-nit and resource-ID review comments Drop the redundant Warn before returning an already-wrapped error in NewConnector, downgrade the configured-workspace-not-found skip log to Debug, and document why deployment names are safe as the workspace resource ID. --- pkg/connector/connector.go | 1 - pkg/connector/workspaces.go | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 79f21b92..8655a2e1 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -208,7 +208,6 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect cfg.Workspaces, ) if err != nil { - l.Warn("error creating connector", zap.Error(err)) return nil, nil, err } diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index b26a886c..7b831e74 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -33,6 +33,8 @@ func (w *workspaceBuilder) ResourceType(ctx context.Context) *v2.ResourceType { // minimalWorkspaceResource builds a workspace from just its deployment name, for // token auth where the Account API (and its numeric workspace IDs) is unreachable. +// Deployment names are unique per Databricks cloud (they form the workspace's +// canonical hostname), so they're safe as the resource ID here. // Users, groups and service principals hang off the workspace here instead of the account. func minimalWorkspaceResource(_ context.Context, workspace *databricks.Workspace, parent *v2.ResourceId) (*v2.Resource, error) { return rs.NewGroupResource( @@ -130,7 +132,7 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour l := ctxzap.Extract(ctx) for workspace := range w.workspaces { if _, ok := matchedConfigured[workspace]; !ok { - l.Warn("databricks-connector: configured workspace not found among account workspaces", + l.Debug("databricks-connector: configured workspace not found among account workspaces", zap.String("workspace", workspace), ) } From 423d12edc6950ffb693d6eced459831140a47b40 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 13 Aug 2026 15:45:49 -0500 Subject: [PATCH 10/17] CXH-2166: fix group grant parenting under token auth, warn on empty workspace match, refresh generated schema Groups sync parented under the workspace when the account API is unavailable (token auth), but roleBuilder and servicePrincipalBuilder still built account-parented (or unparented) group grant principals, so those grants referenced resources that were never synced. Also add an aggregate warning when a configured --workspaces filter matches nothing, broaden the match to name/deployment-name/numeric-ID like the exclude-list matcher, and regenerate config_schema.json plus the README flag dump to match pkg/config/config.go. --- README.md | 2 +- config_schema.json | 8 +++-- pkg/connector/helpers.go | 9 ++++++ pkg/connector/helpers_test.go | 49 +++++++++++++++++++++++++++++ pkg/connector/roles.go | 5 ++- pkg/connector/service-principals.go | 3 +- pkg/connector/workspaces.go | 35 +++++++++++++++------ 7 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 pkg/connector/helpers_test.go diff --git a/README.md b/README.md index cb710faa..1a0b9202 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Flags: -v, --version version for baton-databricks --workers int The number of sync workers to use. -1 for auto-detect, 0 for sequential, >0 for parallel ($BATON_WORKERS) --workspace-tokens strings required: The Databricks personal access tokens scoped to specific workspaces used to connect to the Databricks Workspace API ($BATON_WORKSPACE_TOKENS) - --workspaces strings Limit syncing to the specified workspaces. Required when using workspace tokens. ($BATON_WORKSPACES) + --workspaces strings Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens. ($BATON_WORKSPACES) Use "baton-databricks [command] --help" for more information about a command. ``` diff --git a/config_schema.json b/config_schema.json index b9e53a32..63a5554a 100644 --- a/config_schema.json +++ b/config_schema.json @@ -143,7 +143,7 @@ { "name": "workspaces", "displayName": "Workspaces", - "description": "Limit syncing to the specified workspaces. Required when using workspace tokens.", + "description": "Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens.", "stringSliceField": {} }, { @@ -180,7 +180,8 @@ "hostname", "account-hostname", "workspaces", - "base-url" + "base-url", + "databricks-exclude-workspaces" ], "default": true }, @@ -194,7 +195,8 @@ "workspace-tokens", "hostname", "account-hostname", - "base-url" + "base-url", + "databricks-exclude-workspaces" ] } ] diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 54874a57..d40b343a 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -35,6 +35,15 @@ func parseResourceId(resourceId string) (*v2.ResourceId, *v2.ResourceId, error) return nil, nil, fmt.Errorf("invalid resource ID: %s", resourceId) } +// Mirrors how groupBuilder parents synced groups: account when its API is +// reachable, otherwise the workspace (token auth). +func groupGrantParent(accountAPIAvailable bool, accountId, workspaceId string) (*v2.ResourceId, error) { + if accountAPIAvailable { + return rs.NewResourceID(accountResourceType, accountId) + } + return rs.NewResourceID(workspaceResourceType, workspaceId) +} + func groupGrantExpansion(ctx context.Context, groupId string, parentResource *v2.ResourceId) (*v2.ResourceId, *v2.GrantExpandable, error) { groupResourceStr := groupResourceId(ctx, groupId, parentResource) resourceId, err := rs.NewResourceID(groupResourceType, groupResourceStr) diff --git a/pkg/connector/helpers_test.go b/pkg/connector/helpers_test.go new file mode 100644 index 00000000..e683f87b --- /dev/null +++ b/pkg/connector/helpers_test.go @@ -0,0 +1,49 @@ +package connector + +import ( + "context" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// CXH-2166 regression: under token auth (no account API), groups sync parented +// under the workspace. Role grants to those groups must use the same parent, or +// the grant's principal ID references a group resource that was never synced. +func TestGroupGrantParentMatchesSyncedGroupId(t *testing.T) { + ctx := context.Background() + + t.Run("token auth uses workspace parent", func(t *testing.T) { + parent, err := groupGrantParent(false, "acc-1", "dbc-abc") + if err != nil { + t.Fatalf("groupGrantParent: %v", err) + } + + gotResourceId, _, err := groupGrantExpansion(ctx, "group-1", parent) + if err != nil { + t.Fatalf("groupGrantExpansion: %v", err) + } + + wantId := groupResourceId(ctx, "group-1", &v2.ResourceId{ResourceType: workspaceResourceType.Id, Resource: "dbc-abc"}) + if gotResourceId.Resource != wantId { + t.Errorf("principal ID = %q, want %q (the ID groupBuilder emits for a workspace-parented group)", gotResourceId.Resource, wantId) + } + }) + + t.Run("account API available uses account parent", func(t *testing.T) { + parent, err := groupGrantParent(true, "acc-1", "dbc-abc") + if err != nil { + t.Fatalf("groupGrantParent: %v", err) + } + + gotResourceId, _, err := groupGrantExpansion(ctx, "group-1", parent) + if err != nil { + t.Fatalf("groupGrantExpansion: %v", err) + } + + wantId := groupResourceId(ctx, "group-1", &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: "acc-1"}) + if gotResourceId.Resource != wantId { + t.Errorf("principal ID = %q, want %q (the ID groupBuilder emits for an account-parented group)", gotResourceId.Resource, wantId) + } + }) +} diff --git a/pkg/connector/roles.go b/pkg/connector/roles.go index abed3569..8fb0ec1f 100644 --- a/pkg/connector/roles.go +++ b/pkg/connector/roles.go @@ -226,12 +226,11 @@ func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs } if (!isWorkspaceRole && g.HaveRole(roleName)) || (isWorkspaceRole && g.HaveEntitlement(roleName)) { - accountId := r.client.GetAccountId() - accountResourceId, err := rs.NewResourceID(accountResourceType, accountId) + groupParentResourceId, err := groupGrantParent(r.client.IsAccountAPIAvailable(), r.client.GetAccountId(), workspaceId) if err != nil { return rv, nil, err } - resourceId, expandAnnotation, err := groupGrantExpansion(ctx, g.ID, accountResourceId) + resourceId, expandAnnotation, err := groupGrantExpansion(ctx, g.ID, groupParentResourceId) if err != nil { return rv, nil, err } diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index 62b92868..a51d383c 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -197,7 +197,8 @@ func (s *servicePrincipalBuilder) Grants(ctx context.Context, resource *v2.Resou var annotations []protoreflect.ProtoMessage if resourceId.ResourceType == groupResourceType.Id { - groupResourceStr := groupResourceId(ctx, resourceId.Resource, resource.ParentResourceId) + groupParentResourceId := &v2.ResourceId{ResourceType: parentType, Resource: parentID} + groupResourceStr := groupResourceId(ctx, resourceId.Resource, groupParentResourceId) annotations = append(annotations, &v2.GrantExpandable{ EntitlementIds: []string{fmt.Sprintf("group:%s:%s", groupResourceStr, groupMemberEntitlement)}, }) diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 7b831e74..c1584076 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -112,7 +112,7 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour for _, workspace := range workspaces { // Skip workspaces outside the configured set when one was provided. if len(w.workspaces) > 0 { - cfg, ok := matchConfiguredWorkspace(w.workspaces, workspace.DeploymentName) + cfg, ok := matchConfiguredWorkspace(w.workspaces, workspace.DeploymentName, workspace.Name, strconv.Itoa(workspace.ID)) if !ok { continue } @@ -130,6 +130,15 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour } l := ctxzap.Extract(ctx) + if len(w.workspaces) > 0 && len(matchedConfigured) == 0 { + configured := make([]string, 0, len(w.workspaces)) + for workspace := range w.workspaces { + configured = append(configured, workspace) + } + l.Warn("databricks-connector: none of the configured workspaces matched any account workspace, sync will be empty", + zap.Strings("workspaces", configured), + ) + } for workspace := range w.workspaces { if _, ok := matchedConfigured[workspace]; !ok { l.Debug("databricks-connector: configured workspace not found among account workspaces", @@ -141,15 +150,21 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour return rv, nil, nil } -// matchConfiguredWorkspace looks up deploymentName case-insensitively, returning -// the matched key so warnings can report the value the user configured. -func matchConfiguredWorkspace(configured map[string]struct{}, deploymentName string) (string, bool) { - if _, ok := configured[deploymentName]; ok { - return deploymentName, true - } - for cfg := range configured { - if strings.EqualFold(cfg, deploymentName) { - return cfg, true +// matchConfiguredWorkspace looks up a workspace by deployment name, name, or numeric +// ID case-insensitively (mirroring Client.IsWorkspaceExcluded), returning the matched +// key so warnings can report the value the user configured. +func matchConfiguredWorkspace(configured map[string]struct{}, candidates ...string) (string, bool) { + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, ok := configured[candidate]; ok { + return candidate, true + } + for cfg := range configured { + if strings.EqualFold(cfg, candidate) { + return cfg, true + } } } return "", false From c6917da15ca4eb00839781cb3d658f3322f70dc1 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 13 Aug 2026 17:25:00 -0500 Subject: [PATCH 11/17] CXH-2166: fix remaining review findings for token auth and account grants Warn when every configured workspace is excluded under token auth (previously silent), stop IsWorkspaceExcluded from false-matching on a synthetic zero-value ID, trust an explicit auth-method selection in the OAuth/workspace-token mutual-exclusion check instead of rejecting leftover fields from the unselected group, fix marketplace-admin group grants to parent under the account the same way groups actually sync, and distinguish an intentionally excluded workspace from one that truly wasn't found in the account-API debug log. --- pkg/config/config.go | 10 ++++++---- pkg/config/config_test.go | 34 +++++++++++++++++++++++++++------- pkg/connector/account.go | 6 +++++- pkg/connector/workspaces.go | 21 +++++++++++++++++++-- pkg/databricks/client.go | 9 +++++++-- 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 571728f2..398f4177 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -107,11 +107,13 @@ var Config = field.NewConfiguration( ) // ValidateConfig checks constraints that field groups can't express: OAuth2 and -// workspace-token credentials are mutually exclusive, and a workspace token -// must be paired with the workspace it belongs to when workspace-token auth -// is the selected auth method. +// workspace-token credentials are mutually exclusive when the auth method isn't +// explicitly selected, and a workspace token must be paired with the workspace +// it belongs to when workspace-token auth is the selected auth method. func ValidateConfig(ctx context.Context, cfg *Databricks, authMethod string) error { - if len(cfg.WorkspaceTokens) > 0 && (cfg.DatabricksClientId != "" || cfg.DatabricksClientSecret != "") { + // A merged/stored config can carry both groups' fields; once authMethod picks one, + // prepareClientAuth only reads that group, so the other group's leftovers are inert. + if authMethod == "" && len(cfg.WorkspaceTokens) > 0 && (cfg.DatabricksClientId != "" || cfg.DatabricksClientSecret != "") { return fmt.Errorf("databricks-connector: databricks-client-id/databricks-client-secret and workspace-tokens are mutually exclusive") } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0e3ed635..c88c848f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -34,29 +34,49 @@ func TestValidateConfig(t *testing.T) { } } -// Both auth modes' fields live in the same struct; ValidateConfig must reject -// them being set together regardless of the selected auth method, since field -// groups only validate the fields in the one selected group. -func TestValidateConfigRejectsBothAuthModes(t *testing.T) { +// Both auth modes' fields live in the same struct; with no auth method selected, +// ValidateConfig can't tell which credentials would actually be used, so it must +// reject having both set. +func TestValidateConfigRejectsBothAuthModesWhenAmbiguous(t *testing.T) { cfg := &Databricks{ DatabricksClientId: "client-id", Workspaces: []string{"ws-1"}, WorkspaceTokens: []string{"tok-1"}, } - if err := ValidateConfig(context.Background(), cfg, DatabricksOAuth2Group); err == nil { + if err := ValidateConfig(context.Background(), cfg, ""); err == nil { t.Fatal("expected error, got nil") } } -func TestValidateConfigRejectsClientSecretWithTokens(t *testing.T) { +func TestValidateConfigRejectsClientSecretWithTokensWhenAmbiguous(t *testing.T) { cfg := &Databricks{ DatabricksClientSecret: "client-secret", Workspaces: []string{"ws-1"}, WorkspaceTokens: []string{"tok-1"}, } - if err := ValidateConfig(context.Background(), cfg, DatabricksOAuth2Group); err == nil { + if err := ValidateConfig(context.Background(), cfg, ""); err == nil { t.Fatal("expected error, got nil") } } + +// Once an auth method is explicitly selected, prepareClientAuth only reads that +// group's fields, so leftover values from the other group (e.g. stale OAuth +// creds after switching to workspace tokens, or vice versa) must not block startup. +func TestValidateConfigTrustsExplicitAuthMethod(t *testing.T) { + cfg := &Databricks{ + DatabricksClientId: "client-id", + DatabricksClientSecret: "client-secret", + Workspaces: []string{"ws-1"}, + WorkspaceTokens: []string{"tok-1"}, + } + + if err := ValidateConfig(context.Background(), cfg, DatabricksWorkspaceTokenGroup); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if err := ValidateConfig(context.Background(), cfg, DatabricksOAuth2Group); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} diff --git a/pkg/connector/account.go b/pkg/connector/account.go index 14dc3d80..85e5f51f 100644 --- a/pkg/connector/account.go +++ b/pkg/connector/account.go @@ -130,7 +130,11 @@ func (a *accountBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs var annotations []protoreflect.ProtoMessage if resourceId.ResourceType == groupResourceType.Id { - rid, expandAnnotation, err := groupGrantExpansion(ctx, resourceId.Resource, resource.ParentResourceId) + groupParentResourceId, err := groupGrantParent(a.client.IsAccountAPIAvailable(), a.client.GetAccountId(), "") + if err != nil { + return rv, nil, err + } + rid, expandAnnotation, err := groupGrantExpansion(ctx, resourceId.Resource, groupParentResourceId) if err != nil { return rv, nil, err } diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index c1584076..a1ca0393 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -100,6 +100,16 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour rv = append(rv, wr) } + if len(w.workspaces) > 0 && len(rv) == 0 { + configured := make([]string, 0, len(w.workspaces)) + for workspace := range w.workspaces { + configured = append(configured, workspace) + } + ctxzap.Extract(ctx).Warn("databricks-connector: all configured workspaces are excluded, sync will be empty", + zap.Strings("workspaces", configured), + ) + } + return rv, nil, nil } @@ -140,11 +150,18 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour ) } for workspace := range w.workspaces { - if _, ok := matchedConfigured[workspace]; !ok { - l.Debug("databricks-connector: configured workspace not found among account workspaces", + if _, ok := matchedConfigured[workspace]; ok { + continue + } + if w.client.IsWorkspaceExcluded(workspace) { + l.Debug("databricks-connector: configured workspace was excluded from sync", zap.String("workspace", workspace), ) + continue } + l.Debug("databricks-connector: configured workspace not found among account workspaces", + zap.String("workspace", workspace), + ) } return rv, nil, nil diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 0d3a7ce6..1039ad88 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -119,9 +119,14 @@ func (c *Client) isWorkspaceExcluded(w Workspace) ([]string, bool) { } // IsWorkspaceExcluded reports whether deploymentName matches the -// databricks-exclude-workspaces set. +// databricks-exclude-workspaces set. Checks the name only, not via +// isWorkspaceExcluded: that also matches on ID, and a zero-value ID here would +// let an exclude entry of "0" match every workspace. func (c *Client) IsWorkspaceExcluded(deploymentName string) bool { - _, ok := c.isWorkspaceExcluded(Workspace{DeploymentName: deploymentName}) + if len(c.excludeWorkspaces) == 0 { + return false + } + _, ok := c.excludeWorkspaces[strings.ToLower(deploymentName)] return ok } From 622422dff870bdb444952144aa4e114757805a93 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 13 Aug 2026 17:39:42 -0500 Subject: [PATCH 12/17] CXH-2166: skip groups the rule-sets API rejects instead of failing the sync Live testing against a real tenant under workspace-token auth hit a group present in the workspace SCIM listing that the rule-sets API doesn't recognize (Databricks had auto-generated an orphaned clone of a group). Since token auth can only sync groups scoped to a workspace, and that path had no prior handler for this response, one bad group reference aborted the entire sync instead of just that group's role data. Treat a "not found" 400 from the roles/rule-sets lookup as skip-and-continue, matching how the connector already treats other stale references. --- pkg/connector/groups.go | 12 ++++++++++++ pkg/connector/helpers.go | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index 4091292d..a586e3a2 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -149,6 +149,12 @@ func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, // get all assignable roles for this specific group resource roles, _, err := g.client.ListRoles(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { + if isGroupNotFoundError(err) { + ctxzap.Extract(ctx).Debug("databricks-connector: skipping roles for group not recognized by the rule-sets API", + zap.String("group_id", groupId.Resource), + ) + return rv, nil, nil + } return nil, nil, fmt.Errorf("databricks-connector: failed to list roles for group %s: %w", groupId.Resource, err) } @@ -230,6 +236,12 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S // role permissions grants ruleSets, rateLimitDataRuleSets, err := g.client.ListRuleSets(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { + if isGroupNotFoundError(err) { + l.Debug("databricks-connector: skipping role rule sets for group not recognized by the rule-sets API", + zap.String("group_id", groupId.Resource), + ) + return rv, &rs.SyncOpResults{Annotations: annos}, nil + } return nil, nil, fmt.Errorf("databricks-connector: failed to list role rule sets for group %s: %w", resource.Id.Resource, err) } diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index d40b343a..47b4eadd 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -2,7 +2,9 @@ package connector import ( "context" + "errors" "fmt" + "net/http" "slices" "strings" @@ -153,6 +155,17 @@ func preparePrincipalId(ctx context.Context, c *databricks.Client, workspaceId, return result, nil } +// isGroupNotFoundError matches the rule-sets/roles API's response for a group ID +// it doesn't recognize (e.g. an orphaned or stale workspace SCIM group), distinct +// from other 400s. +func isGroupNotFoundError(err error) bool { + var apiErr *databricks.APIError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusBadRequest && strings.Contains(apiErr.Message, "not found") +} + func isValidPrincipal(principal *v2.ResourceId) bool { return principal.ResourceType == userResourceType.Id || principal.ResourceType == groupResourceType.Id || From b7158d4df2311eab8a0ee3d2575660bc9eef3be7 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Thu, 13 Aug 2026 17:56:47 -0500 Subject: [PATCH 13/17] CXH-2166: tighten group-not-found match, warn on skipped roles, drop dead branch isGroupNotFoundError now requires "group" in the message alongside "not found" so it doesn't swallow unrelated 400s, with test coverage. The two skip-and-continue paths in groups.go log at Warn instead of Debug so degraded syncs are visible at the default log level. account.go's group grant parent now calls rs.NewResourceID directly since the account API guard earlier in the method already guarantees it. --- pkg/connector/account.go | 4 +++- pkg/connector/groups.go | 4 ++-- pkg/connector/helpers.go | 4 +++- pkg/connector/helpers_test.go | 45 +++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/pkg/connector/account.go b/pkg/connector/account.go index 85e5f51f..74ecc964 100644 --- a/pkg/connector/account.go +++ b/pkg/connector/account.go @@ -130,7 +130,9 @@ func (a *accountBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs var annotations []protoreflect.ProtoMessage if resourceId.ResourceType == groupResourceType.Id { - groupParentResourceId, err := groupGrantParent(a.client.IsAccountAPIAvailable(), a.client.GetAccountId(), "") + // Grants already returned early above when the account API is unavailable, + // so groups reaching this point are always account-parented. + groupParentResourceId, err := rs.NewResourceID(accountResourceType, a.client.GetAccountId()) if err != nil { return rv, nil, err } diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index a586e3a2..cefb4490 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -150,7 +150,7 @@ func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, roles, _, err := g.client.ListRoles(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { if isGroupNotFoundError(err) { - ctxzap.Extract(ctx).Debug("databricks-connector: skipping roles for group not recognized by the rule-sets API", + ctxzap.Extract(ctx).Warn("databricks-connector: skipping roles for group not recognized by the rule-sets API", zap.String("group_id", groupId.Resource), ) return rv, nil, nil @@ -237,7 +237,7 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S ruleSets, rateLimitDataRuleSets, err := g.client.ListRuleSets(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { if isGroupNotFoundError(err) { - l.Debug("databricks-connector: skipping role rule sets for group not recognized by the rule-sets API", + l.Warn("databricks-connector: skipping role rule sets for group not recognized by the rule-sets API", zap.String("group_id", groupId.Resource), ) return rv, &rs.SyncOpResults{Annotations: annos}, nil diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 47b4eadd..29065e4e 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -163,7 +163,9 @@ func isGroupNotFoundError(err error) bool { if !errors.As(err, &apiErr) { return false } - return apiErr.StatusCode == http.StatusBadRequest && strings.Contains(apiErr.Message, "not found") + return apiErr.StatusCode == http.StatusBadRequest && + strings.Contains(apiErr.Message, "not found") && + strings.Contains(strings.ToLower(apiErr.Message), "group") } func isValidPrincipal(principal *v2.ResourceId) bool { diff --git a/pkg/connector/helpers_test.go b/pkg/connector/helpers_test.go index e683f87b..5ee784ca 100644 --- a/pkg/connector/helpers_test.go +++ b/pkg/connector/helpers_test.go @@ -2,8 +2,11 @@ package connector import ( "context" + "errors" + "net/http" "testing" + "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" ) @@ -47,3 +50,45 @@ func TestGroupGrantParentMatchesSyncedGroupId(t *testing.T) { } }) } + +func TestIsGroupNotFoundError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "matching group not found", + err: &databricks.APIError{StatusCode: http.StatusBadRequest, Message: "Group 12345 not found"}, + want: true, + }, + { + name: "non-matching 400", + err: &databricks.APIError{StatusCode: http.StatusBadRequest, Message: "invalid role name"}, + want: false, + }, + { + name: "unrelated not-found 400 without group in message", + err: &databricks.APIError{StatusCode: http.StatusBadRequest, Message: "workspace not found"}, + want: false, + }, + { + name: "404 status code", + err: &databricks.APIError{StatusCode: http.StatusNotFound, Message: "Group 12345 not found"}, + want: false, + }, + { + name: "non-APIError", + err: errors.New("connection reset"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isGroupNotFoundError(tt.err); got != tt.want { + t.Errorf("isGroupNotFoundError() = %v, want %v", got, tt.want) + } + }) + } +} From f1a5ff0d1e9fade0a859af69bd46e697652eda3b Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Fri, 14 Aug 2026 06:28:41 -0500 Subject: [PATCH 14/17] CXH-2166: scope group-not-found skip to workspace-parented groups Gate the rule-sets/roles 400 skip in Entitlements and Grants to workspace-parented groups only, so an OAuth/account-parented group hitting the same error shape surfaces a real sync failure instead of being silently skipped. Log the skip as a single Warn naming the group id, matching the portfolio norm, no counter. Fix a casing bug in isGroupNotFoundError's message match, cross-reference the duplicated account/workspace parenting policy, rename IsWorkspaceExcluded to IsWorkspaceNameExcluded, and dedupe two small duplicated loops flagged in review. --- pkg/connector/account.go | 1 + pkg/connector/connector.go | 27 ++++++++++++--------------- pkg/connector/groups.go | 4 ++-- pkg/connector/helpers.go | 9 ++++++--- pkg/connector/helpers_test.go | 5 +++++ pkg/connector/workspaces.go | 26 +++++++++++++------------- pkg/databricks/client.go | 4 ++-- 7 files changed, 41 insertions(+), 35 deletions(-) diff --git a/pkg/connector/account.go b/pkg/connector/account.go index 74ecc964..96aa8f2c 100644 --- a/pkg/connector/account.go +++ b/pkg/connector/account.go @@ -42,6 +42,7 @@ func (a *accountBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return accountResourceType } +// The Account API check below mirrors groupGrantParent (helpers.go); keep both in sync. func (a *accountBuilder) accountResource(_ context.Context) (*v2.Resource, error) { accountId := a.client.GetAccountId() children := []protoreflect.ProtoMessage{ diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 8655a2e1..491dd6bd 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -119,29 +119,26 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err // With an explicit workspace list (always the case for token auth), validate each // configured workspace. Otherwise discover every workspace from the Account API. - if len(d.workspaces) > 0 { - for _, workspace := range d.workspaces { - _, _, err := d.client.ListRoles(ctx, workspace, "", "") - if err != nil && !isAccAPIAvailable { - return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace, err) - } - - isWSAPIAvailable = true - } - } else { + workspaceNames := d.workspaces + if len(workspaceNames) == 0 { workspaces, _, err := d.client.ListWorkspaces(ctx) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) } + workspaceNames = make([]string, 0, len(workspaces)) for _, workspace := range workspaces { - _, _, err := d.client.ListRoles(ctx, workspace.DeploymentName, "", "") - if err != nil && !isAccAPIAvailable { - return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace.DeploymentName, err) - } + workspaceNames = append(workspaceNames, workspace.DeploymentName) + } + } - isWSAPIAvailable = true + for _, workspace := range workspaceNames { + _, _, err := d.client.ListRoles(ctx, workspace, "", "") + if err != nil && !isAccAPIAvailable { + return nil, fmt.Errorf("databricks-connector: failed to validate credentials for workspace %s: %w", workspace, err) } + + isWSAPIAvailable = true } // Resolve the result. diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index cefb4490..7d223f8b 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -149,7 +149,7 @@ func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, // get all assignable roles for this specific group resource roles, _, err := g.client.ListRoles(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { - if isGroupNotFoundError(err) { + if workspaceId != "" && isGroupNotFoundError(err) { ctxzap.Extract(ctx).Warn("databricks-connector: skipping roles for group not recognized by the rule-sets API", zap.String("group_id", groupId.Resource), ) @@ -236,7 +236,7 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S // role permissions grants ruleSets, rateLimitDataRuleSets, err := g.client.ListRuleSets(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { - if isGroupNotFoundError(err) { + if isWorkspaceGroup && isGroupNotFoundError(err) { l.Warn("databricks-connector: skipping role rule sets for group not recognized by the rule-sets API", zap.String("group_id", groupId.Resource), ) diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 29065e4e..be1db76f 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -38,7 +38,9 @@ func parseResourceId(resourceId string) (*v2.ResourceId, *v2.ResourceId, error) } // Mirrors how groupBuilder parents synced groups: account when its API is -// reachable, otherwise the workspace (token auth). +// reachable, otherwise the workspace (token auth). accountResource() in +// account.go encodes the same condition for its child-resource-type list; +// keep both in sync. func groupGrantParent(accountAPIAvailable bool, accountId, workspaceId string) (*v2.ResourceId, error) { if accountAPIAvailable { return rs.NewResourceID(accountResourceType, accountId) @@ -163,9 +165,10 @@ func isGroupNotFoundError(err error) bool { if !errors.As(err, &apiErr) { return false } + msg := strings.ToLower(apiErr.Message) return apiErr.StatusCode == http.StatusBadRequest && - strings.Contains(apiErr.Message, "not found") && - strings.Contains(strings.ToLower(apiErr.Message), "group") + strings.Contains(msg, "not found") && + strings.Contains(msg, "group") } func isValidPrincipal(principal *v2.ResourceId) bool { diff --git a/pkg/connector/helpers_test.go b/pkg/connector/helpers_test.go index 5ee784ca..54aaa341 100644 --- a/pkg/connector/helpers_test.go +++ b/pkg/connector/helpers_test.go @@ -77,6 +77,11 @@ func TestIsGroupNotFoundError(t *testing.T) { err: &databricks.APIError{StatusCode: http.StatusNotFound, Message: "Group 12345 not found"}, want: false, }, + { + name: "mixed case still matches", + err: &databricks.APIError{StatusCode: http.StatusBadRequest, Message: "GROUP 12345 Not Found"}, + want: true, + }, { name: "non-APIError", err: errors.New("connection reset"), diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index a1ca0393..8a594192 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -86,7 +86,7 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour if w.client.IsTokenAuth() { for workspace := range w.workspaces { - if w.client.IsWorkspaceExcluded(workspace) { + if w.client.IsWorkspaceNameExcluded(workspace) { continue } @@ -101,12 +101,8 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour } if len(w.workspaces) > 0 && len(rv) == 0 { - configured := make([]string, 0, len(w.workspaces)) - for workspace := range w.workspaces { - configured = append(configured, workspace) - } ctxzap.Extract(ctx).Warn("databricks-connector: all configured workspaces are excluded, sync will be empty", - zap.Strings("workspaces", configured), + zap.Strings("workspaces", configuredWorkspaceNames(w.workspaces)), ) } @@ -141,19 +137,15 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour l := ctxzap.Extract(ctx) if len(w.workspaces) > 0 && len(matchedConfigured) == 0 { - configured := make([]string, 0, len(w.workspaces)) - for workspace := range w.workspaces { - configured = append(configured, workspace) - } l.Warn("databricks-connector: none of the configured workspaces matched any account workspace, sync will be empty", - zap.Strings("workspaces", configured), + zap.Strings("workspaces", configuredWorkspaceNames(w.workspaces)), ) } for workspace := range w.workspaces { if _, ok := matchedConfigured[workspace]; ok { continue } - if w.client.IsWorkspaceExcluded(workspace) { + if w.client.IsWorkspaceNameExcluded(workspace) { l.Debug("databricks-connector: configured workspace was excluded from sync", zap.String("workspace", workspace), ) @@ -167,8 +159,16 @@ func (w *workspaceBuilder) List(ctx context.Context, parentResourceID *v2.Resour return rv, nil, nil } +func configuredWorkspaceNames(configured map[string]struct{}) []string { + names := make([]string, 0, len(configured)) + for name := range configured { + names = append(names, name) + } + return names +} + // matchConfiguredWorkspace looks up a workspace by deployment name, name, or numeric -// ID case-insensitively (mirroring Client.IsWorkspaceExcluded), returning the matched +// ID case-insensitively (mirroring Client.IsWorkspaceNameExcluded), returning the matched // key so warnings can report the value the user configured. func matchConfiguredWorkspace(configured map[string]struct{}, candidates ...string) (string, bool) { for _, candidate := range candidates { diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 1039ad88..76710023 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -118,11 +118,11 @@ func (c *Client) isWorkspaceExcluded(w Workspace) ([]string, bool) { return keys, len(keys) > 0 } -// IsWorkspaceExcluded reports whether deploymentName matches the +// IsWorkspaceNameExcluded reports whether deploymentName matches the // databricks-exclude-workspaces set. Checks the name only, not via // isWorkspaceExcluded: that also matches on ID, and a zero-value ID here would // let an exclude entry of "0" match every workspace. -func (c *Client) IsWorkspaceExcluded(deploymentName string) bool { +func (c *Client) IsWorkspaceNameExcluded(deploymentName string) bool { if len(c.excludeWorkspaces) == 0 { return false } From 452791801190473b0b23a5acab524bb50e0778bc Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Fri, 14 Aug 2026 12:38:08 -0500 Subject: [PATCH 15/17] CXH-2166: make workspaces and exclude-workspaces mutually exclusive Add a FieldsMutuallyExclusive constraint so a config cannot set both the workspaces allowlist and the databricks-exclude-workspaces denylist, and note the exclusivity in both field descriptions. Regenerate config_schema.json. --- config_schema.json | 13 +++++++++++-- pkg/config/config.go | 9 +++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/config_schema.json b/config_schema.json index 63a5554a..3a76e2e2 100644 --- a/config_schema.json +++ b/config_schema.json @@ -143,7 +143,7 @@ { "name": "workspaces", "displayName": "Workspaces", - "description": "Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens.", + "description": "Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens. Mutually exclusive with databricks-exclude-workspaces.", "stringSliceField": {} }, { @@ -161,10 +161,19 @@ { "name": "databricks-exclude-workspaces", "displayName": "Exclude Workspaces", - "description": "Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID", + "description": "Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces.", "stringSliceField": {} } ], + "constraints": [ + { + "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", + "fieldNames": [ + "workspaces", + "databricks-exclude-workspaces" + ] + } + ], "displayName": "Databricks", "helpUrl": "/docs/baton/databricks", "iconUrl": "/static/app-icons/databricks.svg", diff --git a/pkg/config/config.go b/pkg/config/config.go index 398f4177..0a4de4d9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,7 +34,11 @@ var ( ) WorkspacesField = field.StringSliceField( "workspaces", - field.WithDescription("Limit syncing to the specified workspaces, by deployment name, not workspace ID. Required when using workspace tokens, in the same order as workspace-tokens."), + field.WithDescription( + "Limit syncing to the specified workspaces, by deployment name, not workspace ID. "+ + "Required when using workspace tokens, in the same order as workspace-tokens. "+ + "Mutually exclusive with databricks-exclude-workspaces.", + ), field.WithDisplayName("Workspaces"), ) WorkspaceTokensField = field.StringSliceField( @@ -63,7 +67,7 @@ var ( ) ExcludeWorkspacesField = field.StringSliceField( "databricks-exclude-workspaces", - field.WithDescription("Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID"), + 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"), ) configFields = []field.SchemaField{ @@ -85,6 +89,7 @@ var Config = field.NewConfiguration( field.WithConnectorDisplayName("Databricks"), field.WithHelpUrl("/docs/baton/databricks"), field.WithIconUrl("/static/app-icons/databricks.svg"), + field.WithConstraints(field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField)), field.WithFieldGroups([]field.SchemaFieldGroup{ { Name: DatabricksOAuth2Group, From b691ba6cdc7897640bcda1c5adb3effcd56d8805 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Fri, 14 Aug 2026 13:02:26 -0500 Subject: [PATCH 16/17] CXH-2166: remove EOL username/password auth from docs README and connector.mdx documented a username/password (basic auth) method that the connector doesn't implement and Databricks retired (basic auth reached end of life 2024-07-10). Drop the auth choice, credential set, config-form step, CLI examples, and the BATON_USERNAME/BATON_PASSWORD Kubernetes secret block so the docs match the two auth methods the connector actually supports (OAuth and workspace token). --- README.md | 20 +++++++++----------- docs/connector.mdx | 21 +++------------------ 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 1a0b9202..91a837ec 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,18 @@ 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. This -will decide how connector will be executed. You can use either OAuth client -credentials flow or Basic auth flow (username and password) or Bearer auth flow. -Both OAuth and Basic can be used across account and all workspaces you have -access to. Bearer auth can be used only for a specific workspace. +will decide how connector will be executed. You can use either the OAuth client +credentials flow or the Bearer auth flow. OAuth can be used across account and +all workspaces you have access to. Bearer auth can be used only for a specific +workspace. To use the OAuth, you need to create a service principal and add OAuth secret (client id and secret) to it. You can do that by going to the user management tab and clicking on the Service Principals tab. Then click on the Add Service principal button and name it. You then need to add OAuth secret to it by clicking on the Generate secret button. You can use this secret to authenticate -across all workspaces that service principal has access to. To use basic auth, -you just need to provide a username and password of a user that has access to -the Databricks API. Both methods require admin access to the Databricks account -and each workspace you want to sync. +across all workspaces that service principal has access to. This requires admin +access to the Databricks account and each workspace you want to sync. To use bearer auth, you need to provide a Databricks workspace access token. You can create a new token by logging into the workspace and going into user @@ -55,14 +53,14 @@ baton-databricks --hostname "azuredatabricks.net" ``` brew install conductorone/baton/baton conductorone/baton/baton-databricks -BATON_ACCOUNT_ID=account_id BATON_USERNAME=username BATON_PASSWORD=password baton-databricks +BATON_ACCOUNT_ID=account_id BATON_DATABRICKS_CLIENT_ID=client_id BATON_DATABRICKS_CLIENT_SECRET=client_secret baton-databricks baton resources ``` ## docker ``` -docker run --rm -v $(pwd):/out -e BATON_ACCOUNT_ID=account_id BATON_USERNAME=username BATON_PASSWORD=password ghcr.io/conductorone/baton-databricks:latest -f "/out/sync.c1z" +docker run --rm -v $(pwd):/out -e BATON_ACCOUNT_ID=account_id BATON_DATABRICKS_CLIENT_ID=client_id BATON_DATABRICKS_CLIENT_SECRET=client_secret ghcr.io/conductorone/baton-databricks:latest -f "/out/sync.c1z" docker run --rm -v $(pwd):/out ghcr.io/conductorone/baton:latest -f "/out/sync.c1z" resources ``` @@ -72,7 +70,7 @@ docker run --rm -v $(pwd):/out ghcr.io/conductorone/baton:latest -f "/out/sync.c go install github.com/conductorone/baton/cmd/baton@main go install github.com/conductorone/baton-databricks/cmd/baton-databricks@main -BATON_ACCOUNT_ID=account_id BATON_USERNAME=username BATON_PASSWORD=password baton-databricks +BATON_ACCOUNT_ID=account_id BATON_DATABRICKS_CLIENT_ID=client_id BATON_DATABRICKS_CLIENT_SECRET=client_secret baton-databricks baton resources ``` diff --git a/docs/connector.mdx b/docs/connector.mdx index 0eee30b9..e76e8503 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -42,7 +42,7 @@ A user with the **Account admin** role in each Databricks workspace you want to ### Generate Databricks credentials -You have three authentication choices when setting up the Databricks connector: +You have two authentication choices when setting up the Databricks connector: - **OAuth** (syncs info from all Databricks workspaces) @@ -69,10 +69,6 @@ You have three authentication choices when setting up the Databricks connector: -- **Username and password** (syncs info from all Databricks workspaces) - - You do not need to generate any additional credentials to use this method. - **Done.** Here's the set of credentials you'll need when setting up the connector: - Account ID @@ -85,12 +81,6 @@ OR - Personal access token - Deployment name of the Databricks workspace you're syncing (the subdomain in the workspace URL, not the workspace ID) -OR - -- Account ID -- Username -- Password - Next, move on to the instructions for your chosen setup method. ## Configure the Databricks connector @@ -132,13 +122,13 @@ To complete this task, you'll need: Find the **Settings** area of the page and click **Edit**. - Select whether you're authenticating with **OAuth**, a **Personal access token**, or your **Username and password**. + Select whether you're authenticating with **OAuth** or a **Personal access token**. Paste the account ID you looked up in Step 1 into the **Account ID** field. - Enter the required OAuth, token, or username and password credentials into the other two fields. + Enter the required OAuth or token credentials into the other two fields. **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks account hostname and hostname in the relevant fields. @@ -230,11 +220,6 @@ stringData: BATON_WORKSPACE_TOKENS: BATON_WORKSPACES: - # Databricks credentials, option 3 - BATON_ACCOUNT_ID: - BATON_USERNAME: - BATON_PASSWORD: - # Optional: comma-separated workspaces to exclude from sync (workspace name, deployment name, or numeric ID) BATON_DATABRICKS_EXCLUDE_WORKSPACES: From b06c0543e67c15317606d7ac3d4830cd1deb5217 Mon Sep 17 00:00:00 2001 From: Alejandro Bernal Date: Fri, 14 Aug 2026 13:21:45 -0500 Subject: [PATCH 17/17] CXH-2166: require workspaces when workspace-tokens is set Add a FieldsDependentOn constraint so the schema declares workspaces as required whenever workspace-tokens is provided, matching the field description. Use FieldsDependentOn rather than FieldsRequiredTogether: the latter is symmetric and would also reject a valid OAuth config that sets --workspaces alone to scope the sync. The ValidateConfig length check stays, since the constraint only enforces presence, not the positional equal-length pairing. --- config_schema.json | 9 +++++++++ pkg/config/config.go | 11 ++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/config_schema.json b/config_schema.json index 3a76e2e2..b38554d0 100644 --- a/config_schema.json +++ b/config_schema.json @@ -172,6 +172,15 @@ "workspaces", "databricks-exclude-workspaces" ] + }, + { + "kind": "CONSTRAINT_KIND_DEPENDENT_ON", + "fieldNames": [ + "workspace-tokens" + ], + "secondaryFieldNames": [ + "workspaces" + ] } ], "displayName": "Databricks", diff --git a/pkg/config/config.go b/pkg/config/config.go index 0a4de4d9..2f27e771 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -89,7 +89,10 @@ var Config = field.NewConfiguration( field.WithConnectorDisplayName("Databricks"), field.WithHelpUrl("/docs/baton/databricks"), field.WithIconUrl("/static/app-icons/databricks.svg"), - field.WithConstraints(field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField)), + field.WithConstraints( + field.FieldsMutuallyExclusive(WorkspacesField, ExcludeWorkspacesField), + field.FieldsDependentOn([]field.SchemaField{WorkspaceTokensField}, []field.SchemaField{WorkspacesField}), + ), field.WithFieldGroups([]field.SchemaFieldGroup{ { Name: DatabricksOAuth2Group, @@ -111,10 +114,8 @@ var Config = field.NewConfiguration( }), ) -// ValidateConfig checks constraints that field groups can't express: OAuth2 and -// workspace-token credentials are mutually exclusive when the auth method isn't -// explicitly selected, and a workspace token must be paired with the workspace -// it belongs to when workspace-token auth is the selected auth method. +// ValidateConfig enforces what field groups can't: OAuth/token exclusion when no +// auth method is set, and equal-length workspaces/workspace-tokens. func ValidateConfig(ctx context.Context, cfg *Databricks, authMethod string) error { // A merged/stored config can carry both groups' fields; once authMethod picks one, // prepareClientAuth only reads that group, so the other group's leftovers are inert.