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