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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ sidebarTitle: "Databricks"
| Groups | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Roles | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Service principals | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Service principal secrets | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | β€” |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Workspace personal access tokens | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | β€” |

The Databricks connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning).

Expand Down
2 changes: 2 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment on lines +31 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ Bug: These builders expect fan-out over parent resources (servicePrincipalResourceType and workspaceResourceType respectively), but neither parent resource declares the new types as children via ChildResourceType annotations. Without those annotations, the SDK will never call these builders' List methods with a parent resource ID β€” so no SP secrets or workspace PATs will be synced.

Fix: Add &v2.ChildResourceType{ResourceTypeId: servicePrincipalSecretResourceType.Id} to the SP resource in service-principals.go:servicePrincipalResource, and &v2.ChildResourceType{ResourceTypeId: workspacePATResourceType.Id} to the workspace resource in workspaces.go:workspaceResource.

}

return syncers
Expand Down
14 changes: 14 additions & 0 deletions pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
)
4 changes: 4 additions & 0 deletions pkg/connector/service-principals.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions pkg/connector/sp_secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 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
}

l := ctxzap.Extract(ctx)
spID := parentResourceID.Resource

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)
}

l.Debug("listed SP OAuth secrets", zap.String("sp_id", spID), zap.Int("count", len(secrets)))

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
}
rv = append(rv, r)
}

if nextToken == "" {
return rv, nil, nil
}
return rv, &rs.SyncOpResults{NextPageToken: nextToken}, 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}
}
198 changes: 198 additions & 0 deletions pkg/connector/sp_secrets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package connector

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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"
)

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")
require.NoError(t, 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"},
}

// 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")
callCount++
if r.URL.Query().Get("page_token") == "" {
require.NoError(t, json.NewEncoder(w).Encode(map[string]interface{}{
"secrets": page1,
"next_page_token": paginatorCursor,
}))
} else {
require.NoError(t, 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",
}

// 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, 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)
require.Equal(t, "s2", page2Resources[0].Id.Resource)
}

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")
require.NoError(t, 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())
}
Loading
Loading