Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 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())
}
63 changes: 63 additions & 0 deletions pkg/connector/testhelpers_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading