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). 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/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.go b/pkg/connector/sp_secrets.go new file mode 100644 index 00000000..e5f58191 --- /dev/null +++ b/pkg/connector/sp_secrets.go @@ -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} +} diff --git a/pkg/connector/sp_secrets_test.go b/pkg/connector/sp_secrets_test.go new file mode 100644 index 00000000..848d18cc --- /dev/null +++ b/pkg/connector/sp_secrets_test.go @@ -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()) +} 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..c6cefd49 --- /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") + require.NoError(t, 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) + require.NoError(t, 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) + require.NoError(t, 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") + require.NoError(t, 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/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 3ae7d092..1c09c717 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" //nolint:gosec // URL path, not a credential ) 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} +}