From c57ebd27645d463b159489b0e99116da65441193 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 17:53:36 +0000 Subject: [PATCH 1/5] nhi: sync SP OAuth secrets + workspace PATs as STATIC_SECRET resources (Phase-1 class-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new TRAIT_SECRET resource types to baton-databricks (pqprime NHI program, bd pqprime-16u): A1 — service_principal_secret (SP OAuth client secrets) - New client method ListServicePrincipalSecrets: account-level API GET /api/2.0/accounts/{acct}/servicePrincipals/{sp_id}/credentials/secrets with cursor-based page_token/next_page_token pagination. - Builder fans out per synced SP (parent = service_principal resource). - STATIC_SECRET, detail "databricks.sp_oauth_secret", created_at from create_time, identity_id back-ref to the parent SP resource (K3 APP_REGISTRATION already emitted by the SP builder). - Secret value is never returned by the API; metadata only. A2 — workspace_pat (workspace personal access tokens) - New client method ListTokenManagementTokens: workspace-level admin endpoint GET /api/2.0/token-management/tokens; fans out per synced workspace. - Builder lists all workspace PATs (parent = workspace resource). - STATIC_SECRET, detail "databricks.pat", created_at from creation_time, expires_at from expiry_time (skipped when ≤0), identity_id from created_by_id → user resource. - Graceful degradation: 401/403 responses are logged and the workspace is skipped; the rest of the sync continues. Also adds: - PageTokenVars to pkg/databricks/vars.go for cursor-based pagination. - 13 unit tests covering basic listing, pagination, 401/403 graceful degrade, wrong/nil parent types, timestamp parsing, and no-entitlements invariant. - Both resource types registered in ResourceSyncers. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/connector.go | 2 + pkg/connector/resource_types.go | 14 +++ pkg/connector/sp_secrets.go | 99 +++++++++++++++ pkg/connector/sp_secrets_test.go | 186 +++++++++++++++++++++++++++ pkg/connector/testhelpers_test.go | 63 ++++++++++ pkg/connector/tokens.go | 125 +++++++++++++++++++ pkg/connector/tokens_test.go | 200 ++++++++++++++++++++++++++++++ pkg/databricks/client.go | 58 +++++++++ pkg/databricks/models.go | 21 ++++ pkg/databricks/vars.go | 15 +++ 10 files changed, 783 insertions(+) create mode 100644 pkg/connector/sp_secrets.go create mode 100644 pkg/connector/sp_secrets_test.go create mode 100644 pkg/connector/testhelpers_test.go create mode 100644 pkg/connector/tokens.go create mode 100644 pkg/connector/tokens_test.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 31d679bf..16c9750c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -28,6 +28,8 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newUserBuilder(d.client), newWorkspaceBuilder(d.client), newRoleBuilder(d.client), + newServicePrincipalSecretBuilder(d.client), + newTokenBuilder(d.client), } return syncers diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 748bdbef..e0219675 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -46,4 +46,18 @@ var ( Id: "account", DisplayName: "Account", } + + // servicePrincipalSecretResourceType represents SP OAuth client secrets (TRAIT_SECRET). + servicePrincipalSecretResourceType = &v2.ResourceType{ + Id: "service_principal_secret", + DisplayName: "Service Principal Secret", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, + } + + // workspacePATResourceType represents workspace personal access tokens (TRAIT_SECRET). + workspacePATResourceType = &v2.ResourceType{ + Id: "workspace_pat", + DisplayName: "Workspace Personal Access Token", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, + } ) diff --git a/pkg/connector/sp_secrets.go b/pkg/connector/sp_secrets.go new file mode 100644 index 00000000..5e3b7645 --- /dev/null +++ b/pkg/connector/sp_secrets.go @@ -0,0 +1,99 @@ +package connector + +import ( + "context" + "fmt" + "time" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +type servicePrincipalSecretBuilder struct { + client *databricks.Client +} + +func (b *servicePrincipalSecretBuilder) ResourceType(_ context.Context) *v2.ResourceType { + return servicePrincipalSecretResourceType +} + +func (b *servicePrincipalSecretBuilder) spSecretResource( + secret *databricks.SecretInfo, + spResourceID *v2.ResourceId, +) (*v2.Resource, error) { + traitOpts := []rs.SecretTraitOption{ + rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET), + rs.WithSecretDetail("databricks.sp_oauth_secret"), + rs.WithSecretIdentityID(spResourceID), + } + + if secret.CreateTime != "" { + t, err := time.Parse(time.RFC3339, secret.CreateTime) + if err == nil { + traitOpts = append(traitOpts, rs.WithSecretCreatedAt(t)) + } + } + + return rs.NewSecretResource( + secret.ID, + servicePrincipalSecretResourceType, + secret.ID, + traitOpts, + rs.WithParentResourceID(spResourceID), + ) +} + +// List fans out over the parent service principal and emits a secret resource +// for each OAuth credential belonging to that SP. +func (b *servicePrincipalSecretBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if parentResourceID == nil || parentResourceID.ResourceType != servicePrincipalResourceType.Id { + return nil, nil, nil + } + + l := ctxzap.Extract(ctx) + spID := parentResourceID.Resource + + var rv []*v2.Resource + pageToken := "" + + for { + secrets, nextToken, _, err := b.client.ListServicePrincipalSecrets(ctx, spID, pageToken) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to list SP secrets for %s: %w", spID, err) + } + + l.Debug("listed SP OAuth secrets", zap.String("sp_id", spID), zap.Int("count", len(secrets))) + + for i := range secrets { + r, err := b.spSecretResource(&secrets[i], parentResourceID) + if err != nil { + return nil, nil, err + } + rv = append(rv, r) + } + + if nextToken == "" { + break + } + pageToken = nextToken + } + + return rv, nil, nil +} + +// Entitlements returns nil — secrets carry no access entitlements. +func (b *servicePrincipalSecretBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +// Grants returns nil — secrets carry no grants. +func (b *servicePrincipalSecretBuilder) Grants(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +func newServicePrincipalSecretBuilder(client *databricks.Client) *servicePrincipalSecretBuilder { + return &servicePrincipalSecretBuilder{client: client} +} diff --git a/pkg/connector/sp_secrets_test.go b/pkg/connector/sp_secrets_test.go new file mode 100644 index 00000000..d8a33f37 --- /dev/null +++ b/pkg/connector/sp_secrets_test.go @@ -0,0 +1,186 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/stretchr/testify/require" +) + +func TestSPSecretBuilder_List_Basic(t *testing.T) { + secrets := []map[string]interface{}{ + { + "id": "secret-1", + "create_time": "2025-01-15T10:00:00Z", + "update_time": "2025-01-15T10:00:00Z", + "secret_hash": "abc123", + "status": "ACTIVE", + }, + { + "id": "secret-2", + "create_time": "2025-03-20T12:30:00Z", + "update_time": "2025-03-20T12:30:00Z", + "secret_hash": "def456", + "status": "ACTIVE", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "secrets": secrets, + "next_page_token": "", + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newServicePrincipalSecretBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: "12345", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Len(t, resources, 2) + + for _, r := range resources { + require.Equal(t, servicePrincipalSecretResourceType.Id, r.Id.ResourceType) + require.Equal(t, parentID.Resource, r.ParentResourceId.Resource) + require.Equal(t, servicePrincipalResourceType.Id, r.ParentResourceId.ResourceType) + + trait := getSecretTrait(t, r) + require.Equal(t, v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, trait.GetCredentialType()) + require.Equal(t, "databricks.sp_oauth_secret", trait.GetCredentialDetail()) + require.Equal(t, parentID.Resource, trait.GetIdentityId().GetResource()) + require.Equal(t, servicePrincipalResourceType.Id, trait.GetIdentityId().GetResourceType()) + } + + ids := map[string]bool{} + for _, r := range resources { + ids[r.Id.Resource] = true + } + require.True(t, ids["secret-1"]) + require.True(t, ids["secret-2"]) +} + +func TestSPSecretBuilder_List_Pagination(t *testing.T) { + page1 := []map[string]interface{}{ + {"id": "s1", "create_time": "2025-01-01T00:00:00Z", "secret_hash": "h1", "status": "ACTIVE"}, + } + page2 := []map[string]interface{}{ + {"id": "s2", "create_time": "2025-02-01T00:00:00Z", "secret_hash": "h2", "status": "ACTIVE"}, + } + + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if r.URL.Query().Get("page_token") == "" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "secrets": page1, + "next_page_token": "cursor-page-2", + }) + } else { + json.NewEncoder(w).Encode(map[string]interface{}{ + "secrets": page2, + "next_page_token": "", + }) + } + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newServicePrincipalSecretBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: "99", + } + + resources, _, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, 2, callCount) + + ids := map[string]bool{} + for _, r := range resources { + ids[r.Id.Resource] = true + } + require.True(t, ids["s1"]) + require.True(t, ids["s2"]) +} + +func TestSPSecretBuilder_List_WrongParentType(t *testing.T) { + builder := newServicePrincipalSecretBuilder(nil) + + parentID := &v2.ResourceId{ + ResourceType: workspaceResourceType.Id, + Resource: "my-ws", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestSPSecretBuilder_List_NilParent(t *testing.T) { + builder := newServicePrincipalSecretBuilder(nil) + + resources, results, err := builder.List(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestSPSecretBuilder_NoEntitlementsOrGrants(t *testing.T) { + builder := newServicePrincipalSecretBuilder(nil) + + ents, _, err := builder.Entitlements(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, ents) + + grants, _, err := builder.Grants(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, grants) +} + +func TestSPSecretBuilder_List_CreatedAtParsed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "secrets": []map[string]interface{}{ + { + "id": "s-ts", + "create_time": "2025-06-01T09:00:00Z", + "secret_hash": "xhash", + "status": "ACTIVE", + }, + }, + "next_page_token": "", + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newServicePrincipalSecretBuilder(client) + + parentID := &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "777"} + resources, _, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, resources, 1) + + trait := getSecretTrait(t, resources[0]) + require.NotNil(t, trait.GetCreatedAt()) + // 2025-06-01T09:00:00Z = Unix 1748768400 + require.Equal(t, int64(1748768400), trait.GetCreatedAt().GetSeconds()) +} diff --git a/pkg/connector/testhelpers_test.go b/pkg/connector/testhelpers_test.go new file mode 100644 index 00000000..dbf2c26b --- /dev/null +++ b/pkg/connector/testhelpers_test.go @@ -0,0 +1,63 @@ +package connector + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/stretchr/testify/require" +) + +// redirectTransport rewrites every request's host/scheme to a fixed target so +// tests run against a local httptest.Server without real credentials. +type redirectTransport struct { + scheme string + host string +} + +func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = t.scheme + clone.URL.Host = t.host + clone.Host = t.host + return http.DefaultTransport.RoundTrip(clone) +} + +// newTestDatabricksClient creates a Client that points all HTTP traffic to srv. +func newTestDatabricksClient(t *testing.T, srv *httptest.Server) *databricks.Client { + t.Helper() + + parsed, err := url.Parse(srv.URL) + require.NoError(t, err) + + transport := &redirectTransport{scheme: parsed.Scheme, host: parsed.Host} + httpClient := &http.Client{Transport: transport} + + client, err := databricks.NewClient( + context.Background(), + httpClient, + "workspace.cloud.databricks.com", + "accounts.cloud.databricks.com", + "test-account-id", + srv.URL, + &databricks.NoAuth{}, + ) + require.NoError(t, err) + return client +} + +// getSecretTrait extracts the SecretTrait from a resource's annotations. +func getSecretTrait(t *testing.T, r *v2.Resource) *v2.SecretTrait { + t.Helper() + trait := &v2.SecretTrait{} + annos := annotations.Annotations(r.GetAnnotations()) + ok, err := annos.Pick(trait) + require.NoError(t, err) + require.True(t, ok, "SecretTrait not found on resource %s", r.Id.Resource) + return trait +} diff --git a/pkg/connector/tokens.go b/pkg/connector/tokens.go new file mode 100644 index 00000000..9e9eeee8 --- /dev/null +++ b/pkg/connector/tokens.go @@ -0,0 +1,125 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// millisToTime converts a Unix-millisecond timestamp to time.Time. +// Returns zero time if ms is ≤ 0 (Databricks uses -1 for "never expires"). +func millisToTime(ms int64) (time.Time, bool) { + if ms <= 0 { + return time.Time{}, false + } + return time.UnixMilli(ms), true +} + +type tokenBuilder struct { + client *databricks.Client +} + +func (b *tokenBuilder) ResourceType(_ context.Context) *v2.ResourceType { + return workspacePATResourceType +} + +func (b *tokenBuilder) patResource( + token *databricks.TokenInfo, + workspaceResourceID *v2.ResourceId, +) (*v2.Resource, error) { + traitOpts := []rs.SecretTraitOption{ + rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET), + rs.WithSecretDetail("databricks.pat"), + } + + if t, ok := millisToTime(token.CreationTime); ok { + traitOpts = append(traitOpts, rs.WithSecretCreatedAt(t)) + } + + if t, ok := millisToTime(token.ExpiryTime); ok { + traitOpts = append(traitOpts, rs.WithSecretExpiresAt(t)) + } + + // Back-ref the owning user when a user ID is available. + if token.CreatedByID > 0 { + userID := strconv.FormatInt(token.CreatedByID, 10) + userResourceID, err := rs.NewResourceID(userResourceType, userID) + if err == nil { + traitOpts = append(traitOpts, rs.WithSecretIdentityID(userResourceID)) + } + } + + displayName := token.TokenID + if token.Comment != "" { + displayName = token.Comment + } + + return rs.NewSecretResource( + displayName, + workspacePATResourceType, + token.TokenID, + traitOpts, + rs.WithParentResourceID(workspaceResourceID), + ) +} + +// List fans out over the parent workspace and emits a PAT resource for each +// token. If the configured account SP lacks workspace-admin, the endpoint +// returns 403; we log and skip that workspace rather than failing the whole sync. +func (b *tokenBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if parentResourceID == nil || parentResourceID.ResourceType != workspaceResourceType.Id { + return nil, nil, nil + } + + l := ctxzap.Extract(ctx) + workspaceDeploymentName := parentResourceID.Resource + + tokens, _, err := b.client.ListTokenManagementTokens(ctx, workspaceDeploymentName) + if err != nil { + var apiErr *databricks.APIError + if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusForbidden || apiErr.StatusCode == http.StatusUnauthorized) { + l.Warn("databricks-connector: skipping workspace PATs — account SP lacks workspace-admin", + zap.String("workspace", workspaceDeploymentName), + zap.Int("status", apiErr.StatusCode), + ) + return nil, nil, nil + } + return nil, nil, fmt.Errorf("databricks-connector: failed to list tokens for workspace %s: %w", workspaceDeploymentName, err) + } + + l.Debug("listed workspace PATs", zap.String("workspace", workspaceDeploymentName), zap.Int("count", len(tokens))) + + rv := make([]*v2.Resource, 0, len(tokens)) + for i := range tokens { + r, err := b.patResource(&tokens[i], parentResourceID) + if err != nil { + return nil, nil, err + } + rv = append(rv, r) + } + + return rv, nil, nil +} + +// Entitlements returns nil — PATs carry no access entitlements. +func (b *tokenBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +// Grants returns nil — PATs carry no grants. +func (b *tokenBuilder) Grants(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +func newTokenBuilder(client *databricks.Client) *tokenBuilder { + return &tokenBuilder{client: client} +} diff --git a/pkg/connector/tokens_test.go b/pkg/connector/tokens_test.go new file mode 100644 index 00000000..5683ac57 --- /dev/null +++ b/pkg/connector/tokens_test.go @@ -0,0 +1,200 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/stretchr/testify/require" +) + +func TestTokenBuilder_List_Basic(t *testing.T) { + tokens := []map[string]interface{}{ + { + "token_id": "tok-abc", + "comment": "CI token", + "creation_time": int64(1748000000000), + "expiry_time": int64(1780000000000), + "created_by_id": int64(101), + "created_by_username": "alice@example.com", + }, + { + "token_id": "tok-def", + "comment": "", + "creation_time": int64(1745000000000), + "expiry_time": int64(-1), + "created_by_id": int64(202), + "created_by_username": "bob@example.com", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "token_infos": tokens, + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newTokenBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: workspaceResourceType.Id, + Resource: "my-workspace", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Len(t, resources, 2) + + for _, r := range resources { + require.Equal(t, workspacePATResourceType.Id, r.Id.ResourceType) + require.Equal(t, parentID.Resource, r.ParentResourceId.Resource) + require.Equal(t, workspaceResourceType.Id, r.ParentResourceId.ResourceType) + + trait := getSecretTrait(t, r) + require.Equal(t, v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, trait.GetCredentialType()) + require.Equal(t, "databricks.pat", trait.GetCredentialDetail()) + require.NotNil(t, trait.GetCreatedAt()) + } + + // tok-abc: has a real expiry; back-ref to user 101; display name = comment + var tokABC *v2.Resource + for _, r := range resources { + if r.Id.Resource == "tok-abc" { + tokABC = r + break + } + } + require.NotNil(t, tokABC) + traitABC := getSecretTrait(t, tokABC) + require.NotNil(t, traitABC.GetExpiresAt()) + require.Equal(t, "101", traitABC.GetIdentityId().GetResource()) + require.Equal(t, userResourceType.Id, traitABC.GetIdentityId().GetResourceType()) + require.Equal(t, "CI token", tokABC.DisplayName) + + // tok-def: expiry_time=-1 → no expires_at; comment="" → display name = token_id + var tokDEF *v2.Resource + for _, r := range resources { + if r.Id.Resource == "tok-def" { + tokDEF = r + break + } + } + require.NotNil(t, tokDEF) + traitDEF := getSecretTrait(t, tokDEF) + require.Nil(t, traitDEF.GetExpiresAt()) + require.Equal(t, "tok-def", tokDEF.DisplayName) +} + +func TestTokenBuilder_List_Forbidden_GracefulDegrade(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "User does not have CAN_MANAGE permission", + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newTokenBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: workspaceResourceType.Id, + Resource: "restricted-workspace", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err, "403 must be swallowed, not returned as an error") + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestTokenBuilder_List_Unauthorized_GracefulDegrade(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Unauthorized", + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newTokenBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: workspaceResourceType.Id, + Resource: "another-workspace", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err, "401 must be swallowed, not returned as an error") + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestTokenBuilder_List_WrongParentType(t *testing.T) { + builder := newTokenBuilder(nil) + + parentID := &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: "sp-123", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestTokenBuilder_List_NilParent(t *testing.T) { + builder := newTokenBuilder(nil) + + resources, results, err := builder.List(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Nil(t, resources) +} + +func TestTokenBuilder_NoEntitlementsOrGrants(t *testing.T) { + builder := newTokenBuilder(nil) + + ents, _, err := builder.Entitlements(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, ents) + + grants, _, err := builder.Grants(context.Background(), nil, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, grants) +} + +func TestTokenBuilder_List_Empty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "token_infos": []interface{}{}, + }) + })) + defer srv.Close() + + client := newTestDatabricksClient(t, srv) + builder := newTokenBuilder(client) + + parentID := &v2.ResourceId{ + ResourceType: workspaceResourceType.Id, + Resource: "empty-workspace", + } + + resources, results, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Nil(t, results) + require.Empty(t, resources) +} diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 3ae7d092..962a7420 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -31,6 +31,14 @@ const ( accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" + + // SP OAuth secrets: account-level, per-SP fan-out. + // https://docs.databricks.com/api/account/serviceprincipalsecrets + accountSPSecretsEndpoint = "/api/2.0/accounts/%s/servicePrincipals/%s/credentials/secrets" + + // Workspace PATs: workspace-level, admin-only. + // https://docs.databricks.com/api/workspace/tokenmanagement + workspaceTokensEndpoint = "/api/2.0/token-management/tokens" ) type Client struct { @@ -686,6 +694,56 @@ func (c *Client) CreateUser( return &res, ratelimitData, nil } +// ListServicePrincipalSecrets lists OAuth client secrets for a service principal. +// Account-level endpoint; no new auth beyond the connector's existing account SP scope. +// https://docs.databricks.com/api/account/serviceprincipalsecrets +func (c *Client) ListServicePrincipalSecrets( + ctx context.Context, + spID string, + pageToken string, +) ( + []SecretInfo, + string, + *v2.RateLimitDescription, + error, +) { + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountSPSecretsEndpoint, c.accountId, spID)) + + var res struct { + Secrets []SecretInfo `json:"secrets"` + NextPageToken string `json:"next_page_token"` + } + ratelimitData, err := c.Get(ctx, u, &res, NewPageTokenVars(pageToken)) + if err != nil { + return nil, "", ratelimitData, err + } + + return res.Secrets, res.NextPageToken, ratelimitData, nil +} + +// ListTokenManagementTokens lists all PATs in a workspace (requires workspace admin). +// https://docs.databricks.com/api/workspace/tokenmanagement +func (c *Client) ListTokenManagementTokens( + ctx context.Context, + workspaceDeploymentName string, +) ( + []TokenInfo, + *v2.RateLimitDescription, + error, +) { + u := c.workspaceUrl(workspaceDeploymentName).JoinPath(workspaceTokensEndpoint) + + var res struct { + TokenInfos []TokenInfo `json:"token_infos"` + } + ratelimitData, err := c.Get(ctx, u, &res) + if err != nil { + return nil, ratelimitData, err + } + + return res.TokenInfos, ratelimitData, nil +} + // https://docs.databricks.com/api/account/accountusers/delete func (c *Client) DeleteUser( ctx context.Context, diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index 0bb3aa8d..a0eb2172 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -140,3 +140,24 @@ type RuleSet struct { Principals []string `json:"principals"` Role string `json:"role"` } + +// SecretInfo is returned by GET /api/2.0/accounts/{account_id}/servicePrincipals/{sp_id}/credentials/secrets. +// The secret value is never returned; only metadata. +type SecretInfo struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + SecretHash string `json:"secret_hash"` + Status string `json:"status"` +} + +// TokenInfo is returned by GET /api/2.0/token-management/tokens (workspace-admin endpoint). +// The token value is never returned; only metadata. +type TokenInfo struct { + TokenID string `json:"token_id"` + Comment string `json:"comment"` + CreationTime int64 `json:"creation_time"` + ExpiryTime int64 `json:"expiry_time"` + CreatedByID int64 `json:"created_by_id"` + CreatedByUsername string `json:"created_by_username"` +} diff --git a/pkg/databricks/vars.go b/pkg/databricks/vars.go index cbf06e05..01ca958e 100644 --- a/pkg/databricks/vars.go +++ b/pkg/databricks/vars.go @@ -167,3 +167,18 @@ func NewNameVars(name string, etag string) *NameVars { Etag: etag, } } + +// PageTokenVars carries a cursor-based page token for APIs that use next_page_token pagination. +type PageTokenVars struct { + Token string +} + +func (p *PageTokenVars) Apply(params *url.Values) { + if p.Token != "" { + params.Set("page_token", p.Token) + } +} + +func NewPageTokenVars(token string) *PageTokenVars { + return &PageTokenVars{Token: token} +} From f04aee3d32983e1fc41e42970853761f38bb57aa Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 18:11:02 +0000 Subject: [PATCH 2/5] fix(nhi): thread page token in SP-secret List; remove internal loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit servicePrincipalSecretBuilder.List was silently unrolling all pages for a given SP inside a single sync-engine call. Use opts.PageToken.Token as the API cursor and return SyncOpResults.NextPageToken so the sync engine drives pagination one page per call — bounded memory, checkpointable. Tests updated to exercise the two-call round-trip instead of the internal-loop behaviour. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/sp_secrets.go | 44 +++++++++++++++----------------- pkg/connector/sp_secrets_test.go | 26 ++++++++++++------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/pkg/connector/sp_secrets.go b/pkg/connector/sp_secrets.go index 5e3b7645..e5f58191 100644 --- a/pkg/connector/sp_secrets.go +++ b/pkg/connector/sp_secrets.go @@ -46,9 +46,11 @@ func (b *servicePrincipalSecretBuilder) spSecretResource( ) } -// List fans out over the parent service principal and emits a secret resource -// for each OAuth credential belonging to that SP. -func (b *servicePrincipalSecretBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { +// List returns one page of OAuth client secrets for the parent service principal. +// The sync engine drives fan-out across SPs via parentResourceID and drives +// pagination across pages by threading SyncOpResults.NextPageToken back in as +// opts.PageToken.Token on the next call. +func (b *servicePrincipalSecretBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, opts rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if parentResourceID == nil || parentResourceID.ResourceType != servicePrincipalResourceType.Id { return nil, nil, nil } @@ -56,32 +58,26 @@ func (b *servicePrincipalSecretBuilder) List(ctx context.Context, parentResource l := ctxzap.Extract(ctx) spID := parentResourceID.Resource - var rv []*v2.Resource - pageToken := "" - - for { - secrets, nextToken, _, err := b.client.ListServicePrincipalSecrets(ctx, spID, pageToken) - if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to list SP secrets for %s: %w", spID, err) - } - - l.Debug("listed SP OAuth secrets", zap.String("sp_id", spID), zap.Int("count", len(secrets))) + secrets, nextToken, _, err := b.client.ListServicePrincipalSecrets(ctx, spID, opts.PageToken.Token) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to list SP secrets for %s: %w", spID, err) + } - for i := range secrets { - r, err := b.spSecretResource(&secrets[i], parentResourceID) - if err != nil { - return nil, nil, err - } - rv = append(rv, r) - } + l.Debug("listed SP OAuth secrets", zap.String("sp_id", spID), zap.Int("count", len(secrets))) - if nextToken == "" { - break + rv := make([]*v2.Resource, 0, len(secrets)) + for i := range secrets { + r, err := b.spSecretResource(&secrets[i], parentResourceID) + if err != nil { + return nil, nil, err } - pageToken = nextToken + rv = append(rv, r) } - return rv, nil, nil + if nextToken == "" { + return rv, nil, nil + } + return rv, &rs.SyncOpResults{NextPageToken: nextToken}, nil } // Entitlements returns nil — secrets carry no access entitlements. diff --git a/pkg/connector/sp_secrets_test.go b/pkg/connector/sp_secrets_test.go index d8a33f37..d73ab357 100644 --- a/pkg/connector/sp_secrets_test.go +++ b/pkg/connector/sp_secrets_test.go @@ -8,6 +8,7 @@ import ( "testing" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/stretchr/testify/require" ) @@ -106,17 +107,24 @@ func TestSPSecretBuilder_List_Pagination(t *testing.T) { Resource: "99", } - resources, _, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + // First page: empty token → returns page 1 + a next-page token. + page1Resources, results1, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) require.NoError(t, err) - require.Len(t, resources, 2) + require.Len(t, page1Resources, 1) + require.NotNil(t, results1, "first call must return a SyncOpResults with a next-page token") + require.NotEmpty(t, results1.NextPageToken) + require.Equal(t, 1, callCount) + require.Equal(t, "s1", page1Resources[0].Id.Resource) + + // Second page: token from first call → returns page 2 + no more token. + page2Resources, results2, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{ + PageToken: pagination.Token{Token: results1.NextPageToken}, + }) + require.NoError(t, err) + require.Len(t, page2Resources, 1) + require.Nil(t, results2, "last page must return nil SyncOpResults") require.Equal(t, 2, callCount) - - ids := map[string]bool{} - for _, r := range resources { - ids[r.Id.Resource] = true - } - require.True(t, ids["s1"]) - require.True(t, ids["s2"]) + require.Equal(t, "s2", page2Resources[0].Id.Resource) } func TestSPSecretBuilder_List_WrongParentType(t *testing.T) { From 53c2f271dfe4c3c0f7a1703e5824e2cdbb213a6b Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 18:34:13 +0000 Subject: [PATCH 3/5] fix(nhi): errcheck in tests, gosec G101 nolint, ChildResourceType annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap json.NewEncoder(w).Encode calls in require.NoError(t, ...) in sp_secrets_test.go and tokens_test.go to satisfy errcheck linter. - Add //nolint:gosec to workspaceTokensEndpoint URL constant (G101 false positive — path string, not a credential). - Add ChildResourceType annotations: SP resource declares servicePrincipalSecretResourceType as a child so the SDK fans out List calls per SP; workspace resource adds workspacePATResourceType alongside the existing roleResourceType child. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service-principals.go | 4 ++++ pkg/connector/sp_secrets_test.go | 16 ++++++++-------- pkg/connector/tokens_test.go | 16 ++++++++-------- pkg/connector/workspaces.go | 1 + pkg/databricks/client.go | 2 +- 5 files changed, 22 insertions(+), 17 deletions(-) diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index 6cc0dd0b..2c4a64c2 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -50,6 +50,10 @@ func (s *servicePrincipalBuilder) servicePrincipalResource(ctx context.Context, "databricks.service_principal", )) + options = append(options, rs.WithAnnotation( + &v2.ChildResourceType{ResourceTypeId: servicePrincipalSecretResourceType.Id}, + )) + resource, err := rs.NewGroupResource( servicePrincipal.DisplayName, servicePrincipalResourceType, diff --git a/pkg/connector/sp_secrets_test.go b/pkg/connector/sp_secrets_test.go index d73ab357..c964609b 100644 --- a/pkg/connector/sp_secrets_test.go +++ b/pkg/connector/sp_secrets_test.go @@ -33,10 +33,10 @@ func TestSPSecretBuilder_List_Basic(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "secrets": secrets, "next_page_token": "", - }) + })) })) defer srv.Close() @@ -86,15 +86,15 @@ func TestSPSecretBuilder_List_Pagination(t *testing.T) { w.Header().Set("Content-Type", "application/json") callCount++ if r.URL.Query().Get("page_token") == "" { - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "secrets": page1, "next_page_token": "cursor-page-2", - }) + })) } else { - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "secrets": page2, "next_page_token": "", - }) + })) } })) defer srv.Close() @@ -165,7 +165,7 @@ func TestSPSecretBuilder_NoEntitlementsOrGrants(t *testing.T) { func TestSPSecretBuilder_List_CreatedAtParsed(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "secrets": []map[string]interface{}{ { "id": "s-ts", @@ -175,7 +175,7 @@ func TestSPSecretBuilder_List_CreatedAtParsed(t *testing.T) { }, }, "next_page_token": "", - }) + })) })) defer srv.Close() diff --git a/pkg/connector/tokens_test.go b/pkg/connector/tokens_test.go index 5683ac57..c6cefd49 100644 --- a/pkg/connector/tokens_test.go +++ b/pkg/connector/tokens_test.go @@ -34,9 +34,9 @@ func TestTokenBuilder_List_Basic(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "token_infos": tokens, - }) + })) })) defer srv.Close() @@ -97,9 +97,9 @@ func TestTokenBuilder_List_Forbidden_GracefulDegrade(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "message": "User does not have CAN_MANAGE permission", - }) + })) })) defer srv.Close() @@ -121,9 +121,9 @@ func TestTokenBuilder_List_Unauthorized_GracefulDegrade(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Unauthorized", - }) + })) })) defer srv.Close() @@ -179,9 +179,9 @@ func TestTokenBuilder_NoEntitlementsOrGrants(t *testing.T) { func TestTokenBuilder_List_Empty(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "token_infos": []interface{}{}, - }) + })) })) defer srv.Close() diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 123d1351..94ac31ae 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -45,6 +45,7 @@ func workspaceResource(_ context.Context, workspace *databricks.Workspace, paren rs.WithParentResourceID(parent), rs.WithAnnotation( &v2.ChildResourceType{ResourceTypeId: roleResourceType.Id}, + &v2.ChildResourceType{ResourceTypeId: workspacePATResourceType.Id}, ), ) diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 962a7420..1c09c717 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -38,7 +38,7 @@ const ( // Workspace PATs: workspace-level, admin-only. // https://docs.databricks.com/api/workspace/tokenmanagement - workspaceTokensEndpoint = "/api/2.0/token-management/tokens" + workspaceTokensEndpoint = "/api/2.0/token-management/tokens" //nolint:gosec // URL path, not a credential ) type Client struct { From c2b5adda0fd09ab7fb4f592e5f991c3e3665b7c2 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 18:42:48 +0000 Subject: [PATCH 4/5] fix(nhi): extract paginator cursor to named const to avoid gosec G101 gosec v2.11.4 (used by CI) flags the hardcoded "cursor-page-2" string as a potential credential when it appears as the value of a key whose name contains "token". Rename it to paginatorCursor so the variable name carries no sensitive semantics. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/sp_secrets_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/connector/sp_secrets_test.go b/pkg/connector/sp_secrets_test.go index c964609b..848d18cc 100644 --- a/pkg/connector/sp_secrets_test.go +++ b/pkg/connector/sp_secrets_test.go @@ -81,6 +81,10 @@ func TestSPSecretBuilder_List_Pagination(t *testing.T) { {"id": "s2", "create_time": "2025-02-01T00:00:00Z", "secret_hash": "h2", "status": "ACTIVE"}, } + // Use a named constant so gosec does not flag the paginator cursor string as + // a hardcoded credential — it is not; it is a mock API continuation marker. + const paginatorCursor = "cursor-page-2" + callCount := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -88,7 +92,7 @@ func TestSPSecretBuilder_List_Pagination(t *testing.T) { if r.URL.Query().Get("page_token") == "" { require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ "secrets": page1, - "next_page_token": "cursor-page-2", + "next_page_token": paginatorCursor, })) } else { require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{ From da590ad61ad9e8b01d3c2ebcdc3bac8ebbcbd01f Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Mon, 15 Jun 2026 06:10:58 +0000 Subject: [PATCH 5/5] docs: add service principal secrets and workspace PATs to capabilities table The PR introduced two new sync-only resource types (service_principal_secret and workspace_pat) but the docs/connector.mdx capabilities table and README data model list were not updated. Add rows for both types with sync enabled and provision disabled. Co-authored-by: c1-squire-dev[bot] --- README.md | 2 ++ docs/connector.mdx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 8aa00a07..20a5d96f 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,10 @@ baton resources - Workspaces - Groups - Service Principals +- Service Principal Secrets - Users - Roles +- Workspace Personal Access Tokens By default, connector will fetch all resources from the account and all workspaces. You can limit the scope of the sync by providing a list of diff --git a/docs/connector.mdx b/docs/connector.mdx index 88a0a418..3e721261 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -14,7 +14,9 @@ sidebarTitle: "Databricks" | Groups | | | | Roles | | | | Service principals | | | +| Service principal secrets | | — | | Workspaces | | | +| Workspace personal access tokens | | — | The Databricks connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning).