From 311fcbe02a6319710db8a6841f95102bae5b2786 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 21 Jul 2026 22:23:08 +0000 Subject: [PATCH 01/11] feat(credentials): issue Databricks client secrets Prototype OAuth client-secret issuance, sync, TTL, and secret-safe HTTP handling. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/connector.go | 1 + pkg/connector/resource_types.go | 9 ++ pkg/connector/service-principals.go | 5 + pkg/connector/service_principal_secrets.go | 143 ++++++++++++++++++ .../service_principal_secrets_test.go | 58 +++++++ pkg/databricks/client.go | 34 ++++- pkg/databricks/models.go | 19 +++ pkg/databricks/request.go | 3 +- pkg/databricks/vars.go | 12 ++ .../pkg/connectorbuilder/connectorbuilder.go | 21 +++ .../baton-sdk/pkg/crypto/crypto.go | 20 +++ 11 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/service_principal_secrets.go create mode 100644 pkg/connector/service_principal_secrets_test.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 31d679bf..61bcd39b 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -25,6 +25,7 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newAccountBuilder(d.client), newGroupBuilder(d.client), newServicePrincipalBuilder(d.client), + newServicePrincipalSecretBuilder(d.client), newUserBuilder(d.client), newWorkspaceBuilder(d.client), newRoleBuilder(d.client), diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 748bdbef..ef207c79 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -2,6 +2,7 @@ package connector import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" ) var ( @@ -25,6 +26,14 @@ var ( Id: "service_principal", DisplayName: "Service Principal", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(&v2.ChildResourceType{ResourceTypeId: servicePrincipalSecretResourceType.Id}), + } + + servicePrincipalSecretResourceType = &v2.ResourceType{ + Id: "service_principal_secret", + DisplayName: "Service Principal Secret", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, + Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}), } // The role resource type is for all static roles and entitlements available in API. diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index 6cc0dd0b..d90cba97 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -8,6 +8,7 @@ import ( "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/conductorone/baton-sdk/pkg/connectorbuilder" ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" @@ -19,8 +20,11 @@ import ( type servicePrincipalBuilder struct { client *databricks.Client resourceType *v2.ResourceType + createSecret func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) } +var _ connectorbuilder.CredentialIssuerV2 = (*servicePrincipalBuilder)(nil) + func (s *servicePrincipalBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return servicePrincipalResourceType } @@ -397,5 +401,6 @@ func newServicePrincipalBuilder(client *databricks.Client) *servicePrincipalBuil return &servicePrincipalBuilder{ client: client, resourceType: servicePrincipalResourceType, + createSecret: client.CreateServicePrincipalSecret, } } diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go new file mode 100644 index 00000000..0d40b09a --- /dev/null +++ b/pkg/connector/service_principal_secrets.go @@ -0,0 +1,143 @@ +package connector + +import ( + "context" + "fmt" + "time" + + "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/conductorone/baton-sdk/pkg/types/resource" +) + +type servicePrincipalSecretBuilder struct { + client *databricks.Client +} + +func newServicePrincipalSecretBuilder(client *databricks.Client) *servicePrincipalSecretBuilder { + return &servicePrincipalSecretBuilder{client: client} +} + +func (s *servicePrincipalSecretBuilder) ResourceType(context.Context) *v2.ResourceType { + return servicePrincipalSecretResourceType +} + +func (s *servicePrincipalSecretBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + attr resource.SyncOpAttrs, +) ([]*v2.Resource, *resource.SyncOpResults, error) { + if parentResourceID == nil { + return nil, nil, nil + } + + response, err := s.client.ListServicePrincipalSecrets(ctx, parentResourceID.GetResource(), attr.PageToken.Token) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: list service principal secrets: %w", err) + } + + resources := make([]*v2.Resource, 0, len(response.Secrets)) + for i := range response.Secrets { + secret, err := servicePrincipalSecretResource(parentResourceID, &response.Secrets[i]) + if err != nil { + return nil, nil, err + } + resources = append(resources, secret) + } + return resources, &resource.SyncOpResults{NextPageToken: response.NextPageToken}, nil +} + +func (*servicePrincipalSecretBuilder) Entitlements(context.Context, *v2.Resource, resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) { + return nil, nil, nil +} + +func (*servicePrincipalSecretBuilder) Grants(context.Context, *v2.Resource, resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) { + return nil, nil, nil +} + +func (s *servicePrincipalBuilder) Issue( + ctx context.Context, + identityID *v2.ResourceId, + credentialOptions *v2.LocalCredentialOptions, +) (*v2.Resource, []*v2.PlaintextData, annotations.Annotations, error) { + if identityID == nil || identityID.GetResourceType() != servicePrincipalResourceType.Id { + return nil, nil, nil, fmt.Errorf("databricks-connector: invalid service principal identity") + } + clientSecret := credentialOptions.GetClientSecret() + if clientSecret == nil { + return nil, nil, nil, fmt.Errorf("databricks-connector: only OAuth client-secret credentials are supported") + } + + lifetime := "" + if ttl := clientSecret.GetTtl(); ttl != nil { + if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.GetNanos() != 0 { + return nil, nil, nil, fmt.Errorf("databricks-connector: invalid client-secret TTL") + } + lifetime = fmt.Sprintf("%ds", ttl.GetSeconds()) + } + + created, err := s.createSecret(ctx, identityID.GetResource(), lifetime) + if err != nil { + return nil, nil, nil, fmt.Errorf("databricks-connector: create service principal secret: %w", err) + } + if created.Secret == "" { + return nil, nil, nil, fmt.Errorf("databricks-connector: create service principal secret returned no secret material") + } + + plaintext := []byte(created.Secret) + created.Secret = "" + secret, err := servicePrincipalSecretResource(identityID, created) + if err != nil { + return nil, nil, nil, err + } + return secret, []*v2.PlaintextData{{ + Name: "client_secret", + Description: "Databricks OAuth client secret", + Bytes: plaintext, + }}, nil, nil +} + +func (*servicePrincipalBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { + return &v2.CredentialDetailsCredentialIssue{ + SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{ + v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + }, + PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + }, nil, nil +} + +func servicePrincipalSecretResource(identityID *v2.ResourceId, secret *databricks.ServicePrincipalSecret) (*v2.Resource, error) { + if secret.ID == "" { + return nil, fmt.Errorf("databricks-connector: service principal secret has no ID") + } + + secretOptions := []resource.SecretTraitOption{ + resource.WithSecretIdentityID(identityID), + resource.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET), + resource.WithSecretDetail("databricks.oauth_client_secret"), + } + if secret.CreateTime != "" { + createdAt, err := time.Parse(time.RFC3339Nano, secret.CreateTime) + if err != nil { + return nil, fmt.Errorf("databricks-connector: parse service principal secret creation time: %w", err) + } + secretOptions = append(secretOptions, resource.WithSecretCreatedAt(createdAt)) + } + if secret.ExpireTime != "" { + expiresAt, err := time.Parse(time.RFC3339Nano, secret.ExpireTime) + if err != nil { + return nil, fmt.Errorf("databricks-connector: parse service principal secret expiry: %w", err) + } + secretOptions = append(secretOptions, resource.WithSecretExpiresAt(expiresAt)) + } + + return resource.NewSecretResource( + secret.ID, + servicePrincipalSecretResourceType, + secret.ID, + secretOptions, + resource.WithParentResourceID(identityID), + resource.WithAnnotation(&v2.RawId{Id: secret.ID}), + ) +} diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go new file mode 100644 index 00000000..0e5354c5 --- /dev/null +++ b/pkg/connector/service_principal_secrets_test.go @@ -0,0 +1,58 @@ +package connector + +import ( + "context" + "testing" + "time" + + "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" + "google.golang.org/protobuf/types/known/durationpb" +) + +func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { + identityID := &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"} + builder := &servicePrincipalBuilder{ + resourceType: servicePrincipalResourceType, + createSecret: func(_ context.Context, servicePrincipalID, lifetime string) (*databricks.ServicePrincipalSecret, error) { + require.Equal(t, "sp-123", servicePrincipalID) + require.Equal(t, "86400s", lifetime) + return &databricks.ServicePrincipalSecret{ + ID: "secret-456", + Secret: "one-time-client-secret", + Status: "ACTIVE", + CreateTime: "2026-07-21T00:00:00.000Z", + ExpireTime: "2026-07-22T00:00:00.000Z", + }, nil + }, + } + + secret, plaintexts, _, err := builder.Issue(context.Background(), identityID, v2.LocalCredentialOptions_builder{ + ClientSecret: v2.LocalCredentialOptions_ClientSecret_builder{Ttl: durationpb.New(24 * time.Hour)}.Build(), + }.Build()) + require.NoError(t, err) + require.Equal(t, "secret-456", secret.GetId().GetResource()) + require.Len(t, plaintexts, 1) + require.Equal(t, "client_secret", plaintexts[0].GetName()) + require.Equal(t, []byte("one-time-client-secret"), plaintexts[0].GetBytes()) + + secretTrait := &v2.SecretTrait{} + annos := annotations.Annotations(secret.GetAnnotations()) + found, err := annos.Pick(secretTrait) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, identityID.GetResource(), secretTrait.GetIdentityId().GetResource()) +} + +func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { + builder := &servicePrincipalBuilder{} + _, _, _, err := builder.Issue(context.Background(), &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: "sp-123", + }, v2.LocalCredentialOptions_builder{ + Token: &v2.LocalCredentialOptions_Token{}, + }.Build()) + require.ErrorContains(t, err, "only OAuth client-secret credentials") +} diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 3ae7d092..083d1dc2 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -29,8 +29,9 @@ const ( accountRolesEndpoint = "/api/2.0/preview/accounts/%s/access-control/assignable-roles" accountRuleSetsEndpoint = "/api/2.0/preview/accounts/%s/access-control/rule-sets" - accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" - accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" + accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" + accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" + accountServicePrincipalSecretsEndpoint = "/api/2.0/accounts/%s/servicePrincipals/%s/credentials/secrets" ) type Client struct { @@ -45,6 +46,35 @@ type Client struct { isWSAPIAvailable bool } +func (c *Client) CreateServicePrincipalSecret( + ctx context.Context, + servicePrincipalID string, + lifetime string, +) (*ServicePrincipalSecret, error) { + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountServicePrincipalSecretsEndpoint, c.accountId, servicePrincipalID)) + var response ServicePrincipalSecret + _, err := c.Post(ctx, u, &CreateServicePrincipalSecretRequest{Lifetime: lifetime}, &response) + if err != nil { + return nil, err + } + return &response, nil +} + +func (c *Client) ListServicePrincipalSecrets( + ctx context.Context, + servicePrincipalID string, + pageToken string, +) (*ListServicePrincipalSecretsResponse, error) { + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountServicePrincipalSecretsEndpoint, c.accountId, servicePrincipalID)) + var response ListServicePrincipalSecretsResponse + params := []Vars{&ServicePrincipalSecretPageVars{PageSize: 100, PageToken: pageToken}} + _, err := c.Get(ctx, u, &response, params...) + if err != nil { + return nil, err + } + return &response, nil +} + func GetAccountHostname(hostname string) string { if strings.HasSuffix(hostname, azureHost) { return "accounts." + azureHost diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index 0bb3aa8d..648595f7 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -94,6 +94,25 @@ type ServicePrincipal struct { ApplicationID string `json:"applicationId"` } +type ServicePrincipalSecret struct { + ID string `json:"id"` + Secret string `json:"secret,omitempty"` + SecretHash string `json:"secret_hash"` + Status string `json:"status"` + CreateTime string `json:"create_time"` + ExpireTime string `json:"expire_time"` + UpdateTime string `json:"update_time"` +} + +type CreateServicePrincipalSecretRequest struct { + Lifetime string `json:"lifetime,omitempty"` +} + +type ListServicePrincipalSecretsResponse struct { + Secrets []ServicePrincipalSecret `json:"secrets"` + NextPageToken string `json:"next_page_token"` +} + func (s ServicePrincipal) HaveRole(role string) bool { for _, r := range s.Roles { if r.Value == role { diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index fe047885..3ab895cc 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -170,7 +170,8 @@ func (c *Client) doRequest( if err == nil { l := ctxzap.Extract(ctx) - l.Debug("do request response", zap.Any("response", response)) + // Responses may contain one-time credentials. Never log response bodies. + l.Debug("request completed", zap.String("method", method), zap.String("url", uri.Redacted())) return ratelimitData, nil } diff --git a/pkg/databricks/vars.go b/pkg/databricks/vars.go index cbf06e05..4d466fc4 100644 --- a/pkg/databricks/vars.go +++ b/pkg/databricks/vars.go @@ -9,6 +9,18 @@ type Vars interface { Apply(params *url.Values) } +type ServicePrincipalSecretPageVars struct { + PageSize int + PageToken string +} + +func (p *ServicePrincipalSecretPageVars) Apply(params *url.Values) { + params.Set("page_size", fmt.Sprintf("%d", p.PageSize)) + if p.PageToken != "" { + params.Set("page_token", p.PageToken) + } +} + // Pagination vars are used for paginating results from the API. type PaginationVars struct { Start uint `json:"startIndex"` diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go index a64d4b58..f2959eb5 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go @@ -516,6 +516,16 @@ func validateCapabilityDetails(_ context.Context, credDetails *v2.CredentialDeta } } + if credDetails.HasCapabilityCredentialIssue() { + // Ensure that the preferred option is included and is part of the supported options + if credDetails.GetCapabilityCredentialIssue().GetPreferredCredentialOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { + return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not set") + } + if !slices.Contains(credDetails.GetCapabilityCredentialIssue().GetSupportedCredentialOptions(), credDetails.GetCapabilityCredentialIssue().GetPreferredCredentialOption()) { + return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not part of the supported options") + } + } + return nil } @@ -635,6 +645,17 @@ func getCredentialDetails(ctx context.Context, b *builder) (*v2.CredentialDetail break // Only need one credential manager's details } + // Check for credential issuance capability details + for _, ci := range b.credentialIssuers { + credentialIssueCapabilityDetails, _, err := ci.IssueCapabilityDetails(ctx) + if err != nil { + l.Error("error: getting credential issuance details", zap.Error(err)) + return nil, fmt.Errorf("error: getting credential issuance details: %w", err) + } + rv.SetCapabilityCredentialIssue(credentialIssueCapabilityDetails) + break // Only need one credential issuer's details + } + err := validateCapabilityDetails(ctx, rv) if err != nil { return nil, fmt.Errorf("error: validating capability details: %w", err) diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go index d1fceab3..2f557ef1 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go @@ -126,6 +126,26 @@ func ConvertCredentialOptions(ctx context.Context, clientSecret *jose.JSONWebKey localOpts.SetSso(v2.LocalCredentialOptions_SSO_builder{ SsoProvider: opts.GetSso().GetSsoProvider(), }.Build()) + case v2.CredentialOptions_ApiKey_case: + localOpts.SetApiKey(v2.LocalCredentialOptions_ApiKey_builder{ + Scopes: opts.GetApiKey().GetScopes(), + Ttl: opts.GetApiKey().GetTtl(), + }.Build()) + case v2.CredentialOptions_Keypair_case: + localOpts.SetKeypair(v2.LocalCredentialOptions_Keypair_builder{ + Algorithm: opts.GetKeypair().GetAlgorithm(), + Bits: opts.GetKeypair().GetBits(), + }.Build()) + case v2.CredentialOptions_Token_case: + localOpts.SetToken(v2.LocalCredentialOptions_Token_builder{ + Scopes: opts.GetToken().GetScopes(), + Ttl: opts.GetToken().GetTtl(), + Audience: opts.GetToken().GetAudience(), + }.Build()) + case v2.CredentialOptions_ClientSecret_case: + localOpts.SetClientSecret(v2.LocalCredentialOptions_ClientSecret_builder{ + Ttl: opts.GetClientSecret().GetTtl(), + }.Build()) case v2.CredentialOptions_EncryptedPassword_case: default: return nil, status.Error(codes.InvalidArgument, "invalid credential options") From b0ec9dd06c373e3dbde6220ff6d5565a477e6c99 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 21 Jul 2026 22:33:46 +0000 Subject: [PATCH 02/11] fix: store Databricks secret creation as resource metadata Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 0d40b09a..62f2fdb9 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -117,12 +117,16 @@ func servicePrincipalSecretResource(identityID *v2.ResourceId, secret *databrick resource.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET), resource.WithSecretDetail("databricks.oauth_client_secret"), } + resourceOptions := []resource.ResourceOption{ + resource.WithParentResourceID(identityID), + resource.WithAnnotation(&v2.RawId{Id: secret.ID}), + } if secret.CreateTime != "" { createdAt, err := time.Parse(time.RFC3339Nano, secret.CreateTime) if err != nil { return nil, fmt.Errorf("databricks-connector: parse service principal secret creation time: %w", err) } - secretOptions = append(secretOptions, resource.WithSecretCreatedAt(createdAt)) + resourceOptions = append(resourceOptions, resource.WithResourceCreatedAt(createdAt)) } if secret.ExpireTime != "" { expiresAt, err := time.Parse(time.RFC3339Nano, secret.ExpireTime) @@ -137,7 +141,6 @@ func servicePrincipalSecretResource(identityID *v2.ResourceId, secret *databrick servicePrincipalSecretResourceType, secret.ID, secretOptions, - resource.WithParentResourceID(identityID), - resource.WithAnnotation(&v2.RawId{Id: secret.ID}), + resourceOptions..., ) } From d451fa52aad7bf502fd1369ffbdba74f0a1475e3 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:13:13 +0000 Subject: [PATCH 03/11] feat(credentials): adopt finalized issuance contract Move lifetime to issuance constraints, use structured outputs, and gracefully omit unreadable service-principal secrets on 403/404. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 62 ++++++++++++------- .../service_principal_secrets_test.go | 18 ++++-- .../internal/connector/noop_provisioner.go | 4 ++ .../pb/c1/connector/v2/resource_grpc.pb.go | 42 ++++++++++++- .../pkg/connectorbuilder/connectorbuilder.go | 16 ++++- .../baton-sdk/pkg/crypto/crypto.go | 23 ++++--- .../baton-sdk/pkg/sdk/empty_connector.go | 4 ++ 7 files changed, 130 insertions(+), 39 deletions(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 62f2fdb9..6809703d 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -2,13 +2,19 @@ package connector import ( "context" + "errors" "fmt" + "net/http" "time" "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/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/durationpb" ) type servicePrincipalSecretBuilder struct { @@ -34,6 +40,11 @@ func (s *servicePrincipalSecretBuilder) List( response, err := s.client.ListServicePrincipalSecrets(ctx, parentResourceID.GetResource(), attr.PageToken.Token) if err != nil { + var apiErr *databricks.APIError + if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusForbidden || apiErr.StatusCode == http.StatusNotFound) { + ctxzap.Extract(ctx).Warn("service principal secrets are not readable; continuing without them", zap.Int("status_code", apiErr.StatusCode)) + return nil, &resource.SyncOpResults{}, nil + } return nil, nil, fmt.Errorf("databricks-connector: list service principal secrets: %w", err) } @@ -58,53 +69,62 @@ func (*servicePrincipalSecretBuilder) Grants(context.Context, *v2.Resource, reso func (s *servicePrincipalBuilder) Issue( ctx context.Context, - identityID *v2.ResourceId, - credentialOptions *v2.LocalCredentialOptions, -) (*v2.Resource, []*v2.PlaintextData, annotations.Annotations, error) { + input *connectorbuilder.CredentialIssueInput, +) (*connectorbuilder.CredentialIssueOutput, error) { + identityID := input.IdentityID if identityID == nil || identityID.GetResourceType() != servicePrincipalResourceType.Id { - return nil, nil, nil, fmt.Errorf("databricks-connector: invalid service principal identity") + return nil, fmt.Errorf("databricks-connector: invalid service principal identity") } - clientSecret := credentialOptions.GetClientSecret() + clientSecret := input.CredentialOptions.GetClientSecret() if clientSecret == nil { - return nil, nil, nil, fmt.Errorf("databricks-connector: only OAuth client-secret credentials are supported") + return nil, fmt.Errorf("databricks-connector: only OAuth client-secret credentials are supported") } lifetime := "" - if ttl := clientSecret.GetTtl(); ttl != nil { + if ttl := input.IssuanceConstraints.GetLifetime(); ttl != nil { if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.GetNanos() != 0 { - return nil, nil, nil, fmt.Errorf("databricks-connector: invalid client-secret TTL") + return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") } lifetime = fmt.Sprintf("%ds", ttl.GetSeconds()) } created, err := s.createSecret(ctx, identityID.GetResource(), lifetime) if err != nil { - return nil, nil, nil, fmt.Errorf("databricks-connector: create service principal secret: %w", err) + return nil, fmt.Errorf("databricks-connector: create service principal secret: %w", err) } if created.Secret == "" { - return nil, nil, nil, fmt.Errorf("databricks-connector: create service principal secret returned no secret material") + return nil, fmt.Errorf("databricks-connector: create service principal secret returned no secret material") } plaintext := []byte(created.Secret) created.Secret = "" secret, err := servicePrincipalSecretResource(identityID, created) if err != nil { - return nil, nil, nil, err + return nil, err } - return secret, []*v2.PlaintextData{{ - Name: "client_secret", - Description: "Databricks OAuth client secret", - Bytes: plaintext, - }}, nil, nil + return &connectorbuilder.CredentialIssueOutput{ + Secret: secret, + PlaintextData: []*v2.PlaintextData{{ + Name: "client_secret", + Description: "Databricks OAuth client secret", + Bytes: plaintext, + }}, + }, nil } func (*servicePrincipalBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { - return &v2.CredentialDetailsCredentialIssue{ - SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{ - v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + return v2.CredentialDetailsCredentialIssue_builder{ + Options: []*v2.CredentialIssueOptionDescriptor{ + v2.CredentialIssueOptionDescriptor_builder{ + Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + Lifetime: v2.IssuanceLifetimeCapability_builder{ + Min: durationpb.New(time.Second), + Granularity: durationpb.New(time.Second), + }.Build(), + }.Build(), }, - PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, - }, nil, nil + PreferredOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + }.Build(), nil, nil } func servicePrincipalSecretResource(identityID *v2.ResourceId, secret *databricks.ServicePrincipalSecret) (*v2.Resource, error) { diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index 0e5354c5..ffd2f1cb 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -8,6 +8,7 @@ import ( "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/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" ) @@ -29,10 +30,15 @@ func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { }, } - secret, plaintexts, _, err := builder.Issue(context.Background(), identityID, v2.LocalCredentialOptions_builder{ - ClientSecret: v2.LocalCredentialOptions_ClientSecret_builder{Ttl: durationpb.New(24 * time.Hour)}.Build(), - }.Build()) + output, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: v2.LocalCredentialOptions_builder{ + ClientSecret: &v2.LocalCredentialOptions_ClientSecret{}, + }.Build(), + IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(24 * time.Hour)}.Build(), + }) require.NoError(t, err) + secret, plaintexts := output.Secret, output.PlaintextData require.Equal(t, "secret-456", secret.GetId().GetResource()) require.Len(t, plaintexts, 1) require.Equal(t, "client_secret", plaintexts[0].GetName()) @@ -48,11 +54,11 @@ func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { builder := &servicePrincipalBuilder{} - _, _, _, err := builder.Issue(context.Background(), &v2.ResourceId{ + _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: &v2.ResourceId{ ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123", - }, v2.LocalCredentialOptions_builder{ + }, CredentialOptions: v2.LocalCredentialOptions_builder{ Token: &v2.LocalCredentialOptions_Token{}, - }.Build()) + }.Build()}) require.ErrorContains(t, err, "only OAuth client-secret credentials") } diff --git a/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go b/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go index df5acbd5..6a6fc8a9 100644 --- a/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go +++ b/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go @@ -38,6 +38,10 @@ func (n *noopProvisioner) IssueCredential(ctx context.Context, request *v2.Issue return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") } +func (n *noopProvisioner) GetCredentialIssueEligibility(ctx context.Context, request *v2.GetCredentialIssueEligibilityRequest) (*v2.GetCredentialIssueEligibilityResponse, error) { + return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") +} + func (n *noopProvisioner) CreateAccount(ctx context.Context, request *v2.CreateAccountRequest) (*v2.CreateAccountResponse, error) { return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") } diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go index 405bf79d..3387b46e 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go @@ -557,8 +557,9 @@ var ResourceDeleterService_ServiceDesc = grpc.ServiceDesc{ } const ( - CredentialManagerService_RotateCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/RotateCredential" - CredentialManagerService_IssueCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/IssueCredential" + CredentialManagerService_RotateCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/RotateCredential" + CredentialManagerService_IssueCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/IssueCredential" + CredentialManagerService_GetCredentialIssueEligibility_FullMethodName = "/c1.connector.v2.CredentialManagerService/GetCredentialIssueEligibility" ) // CredentialManagerServiceClient is the client API for CredentialManagerService service. @@ -573,6 +574,7 @@ type CredentialManagerServiceClient interface { // additional, distinct secret, so an identity can hold multiple coexisting // keys (e.g. cloud service-account key #1 and #2). IssueCredential(ctx context.Context, in *IssueCredentialRequest, opts ...grpc.CallOption) (*IssueCredentialResponse, error) + GetCredentialIssueEligibility(ctx context.Context, in *GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*GetCredentialIssueEligibilityResponse, error) } type credentialManagerServiceClient struct { @@ -603,6 +605,16 @@ func (c *credentialManagerServiceClient) IssueCredential(ctx context.Context, in return out, nil } +func (c *credentialManagerServiceClient) GetCredentialIssueEligibility(ctx context.Context, in *GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*GetCredentialIssueEligibilityResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCredentialIssueEligibilityResponse) + err := c.cc.Invoke(ctx, CredentialManagerService_GetCredentialIssueEligibility_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // CredentialManagerServiceServer is the server API for CredentialManagerService service. // All implementations should embed UnimplementedCredentialManagerServiceServer // for forward compatibility. @@ -615,6 +627,7 @@ type CredentialManagerServiceServer interface { // additional, distinct secret, so an identity can hold multiple coexisting // keys (e.g. cloud service-account key #1 and #2). IssueCredential(context.Context, *IssueCredentialRequest) (*IssueCredentialResponse, error) + GetCredentialIssueEligibility(context.Context, *GetCredentialIssueEligibilityRequest) (*GetCredentialIssueEligibilityResponse, error) } // UnimplementedCredentialManagerServiceServer should be embedded to have @@ -630,6 +643,9 @@ func (UnimplementedCredentialManagerServiceServer) RotateCredential(context.Cont func (UnimplementedCredentialManagerServiceServer) IssueCredential(context.Context, *IssueCredentialRequest) (*IssueCredentialResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method IssueCredential not implemented") } +func (UnimplementedCredentialManagerServiceServer) GetCredentialIssueEligibility(context.Context, *GetCredentialIssueEligibilityRequest) (*GetCredentialIssueEligibilityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCredentialIssueEligibility not implemented") +} func (UnimplementedCredentialManagerServiceServer) testEmbeddedByValue() {} // UnsafeCredentialManagerServiceServer may be embedded to opt out of forward compatibility for this service. @@ -686,6 +702,24 @@ func _CredentialManagerService_IssueCredential_Handler(srv interface{}, ctx cont return interceptor(ctx, in, info, handler) } +func _CredentialManagerService_GetCredentialIssueEligibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCredentialIssueEligibilityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CredentialManagerServiceServer).GetCredentialIssueEligibility(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CredentialManagerService_GetCredentialIssueEligibility_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CredentialManagerServiceServer).GetCredentialIssueEligibility(ctx, req.(*GetCredentialIssueEligibilityRequest)) + } + return interceptor(ctx, in, info, handler) +} + // CredentialManagerService_ServiceDesc is the grpc.ServiceDesc for CredentialManagerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -701,6 +735,10 @@ var CredentialManagerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "IssueCredential", Handler: _CredentialManagerService_IssueCredential_Handler, }, + { + MethodName: "GetCredentialIssueEligibility", + Handler: _CredentialManagerService_GetCredentialIssueEligibility_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "c1/connector/v2/resource.proto", diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go index f2959eb5..74120a51 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go @@ -517,11 +517,21 @@ func validateCapabilityDetails(_ context.Context, credDetails *v2.CredentialDeta } if credDetails.HasCapabilityCredentialIssue() { - // Ensure that the preferred option is included and is part of the supported options - if credDetails.GetCapabilityCredentialIssue().GetPreferredCredentialOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { + issue := credDetails.GetCapabilityCredentialIssue() + if issue.GetPreferredOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not set") } - if !slices.Contains(credDetails.GetCapabilityCredentialIssue().GetSupportedCredentialOptions(), credDetails.GetCapabilityCredentialIssue().GetPreferredCredentialOption()) { + seen := make(map[v2.CapabilityDetailCredentialOption]struct{}, len(issue.GetOptions())) + for _, descriptor := range issue.GetOptions() { + if descriptor == nil || descriptor.GetOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { + return status.Error(codes.InvalidArgument, "error: credential issue option descriptor is invalid") + } + if _, exists := seen[descriptor.GetOption()]; exists { + return status.Errorf(codes.InvalidArgument, "error: duplicate credential issue option %s", descriptor.GetOption()) + } + seen[descriptor.GetOption()] = struct{}{} + } + if _, ok := seen[issue.GetPreferredOption()]; !ok { return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not part of the supported options") } } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go index 2f557ef1..5c70d60a 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go @@ -55,6 +55,20 @@ func (pkem *EncryptionManager) Encrypt(ctx context.Context, cred *v2.PlaintextDa } func NewEncryptionManager(co *v2.CredentialOptions, ec []*v2.EncryptionConfig) (*EncryptionManager, error) { + for i, config := range ec { + if config == nil { + return nil, status.Errorf(codes.InvalidArgument, "encryption config %d is empty", i) + } + provider, err := providers.GetEncryptorForConfig(context.Background(), config) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid encryption config %d: %v", i, err) + } + if validator, ok := provider.(providers.EncryptionConfigValidator); ok { + if err := validator.ValidateConfig(context.Background(), config); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid encryption config %d: %v", i, err) + } + } + } em := &EncryptionManager{ opts: co, configs: ec, @@ -129,23 +143,18 @@ func ConvertCredentialOptions(ctx context.Context, clientSecret *jose.JSONWebKey case v2.CredentialOptions_ApiKey_case: localOpts.SetApiKey(v2.LocalCredentialOptions_ApiKey_builder{ Scopes: opts.GetApiKey().GetScopes(), - Ttl: opts.GetApiKey().GetTtl(), }.Build()) case v2.CredentialOptions_Keypair_case: localOpts.SetKeypair(v2.LocalCredentialOptions_Keypair_builder{ - Algorithm: opts.GetKeypair().GetAlgorithm(), - Bits: opts.GetKeypair().GetBits(), + Profile: opts.GetKeypair().GetProfile(), }.Build()) case v2.CredentialOptions_Token_case: localOpts.SetToken(v2.LocalCredentialOptions_Token_builder{ Scopes: opts.GetToken().GetScopes(), - Ttl: opts.GetToken().GetTtl(), Audience: opts.GetToken().GetAudience(), }.Build()) case v2.CredentialOptions_ClientSecret_case: - localOpts.SetClientSecret(v2.LocalCredentialOptions_ClientSecret_builder{ - Ttl: opts.GetClientSecret().GetTtl(), - }.Build()) + localOpts.SetClientSecret(&v2.LocalCredentialOptions_ClientSecret{}) case v2.CredentialOptions_EncryptedPassword_case: default: return nil, status.Error(codes.InvalidArgument, "invalid credential options") diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go index 1808659a..471d426e 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go @@ -132,6 +132,10 @@ func (n *emptyConnector) IssueCredential(ctx context.Context, request *v2.IssueC return nil, status.Errorf(codes.Unimplemented, "empty connector") } +func (n *emptyConnector) GetCredentialIssueEligibility(ctx context.Context, request *v2.GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*v2.GetCredentialIssueEligibilityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "empty connector") +} + func (n *emptyConnector) CreateResource(ctx context.Context, request *v2.CreateResourceRequest, opts ...grpc.CallOption) (*v2.CreateResourceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "empty connector") } From 126197fffa0e5801bb7cf40e89b0dbe1de0aaddc Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:29:28 +0000 Subject: [PATCH 04/11] fix(resources): use canonical resource attributes Move profiles and status from deprecated trait fields to resource attributes and read provisioning metadata from the canonical resource profile. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/groups.go | 8 ++---- pkg/connector/roles.go | 30 ++++---------------- pkg/connector/service-principals.go | 43 +++++++---------------------- pkg/connector/users.go | 10 ++----- pkg/connector/workspaces.go | 36 +++++++----------------- 5 files changed, 31 insertions(+), 96 deletions(-) diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index cf14eea5..f1468802 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -48,11 +48,7 @@ func groupResource(ctx context.Context, group *databricks.Group, parent *v2.Reso "parent_id": parent.GetResource(), } - groupTraitOptions := []rs.GroupTraitOption{ - rs.WithGroupProfile(profile), - } - - var options []rs.ResourceOption + options := []rs.ResourceOption{rs.WithResourceProfile(profile)} if parent != nil { options = append(options, rs.WithParentResourceID(parent)) } @@ -62,7 +58,7 @@ func groupResource(ctx context.Context, group *databricks.Group, parent *v2.Reso group.DisplayName, groupResourceType, groupId, - groupTraitOptions, + nil, options..., ) diff --git a/pkg/connector/roles.go b/pkg/connector/roles.go index 1e80cf88..78e421c2 100644 --- a/pkg/connector/roles.go +++ b/pkg/connector/roles.go @@ -57,16 +57,13 @@ func roleResource(ctx context.Context, role string, parent *v2.ResourceId) (*v2. roleID = role } - roleTraitOptions := []rs.RoleTraitOption{ - rs.WithRoleProfile(profile), - } - resource, err := rs.NewRoleResource( role, roleResourceType, roleID, - roleTraitOptions, + nil, rs.WithParentResourceID(parent), + rs.WithResourceProfile(profile), ) if err != nil { @@ -136,12 +133,7 @@ func (r *roleBuilder) Entitlements( func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { var rv []*v2.Grant - roleTrait, err := rs.GetRoleTrait(resource) - if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to get role trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(roleTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(resource.GetProfile()) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to get parent info from role profile: %w", err) } @@ -154,7 +146,7 @@ func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs workspaceId = parentID } - roleName, ok := rs.GetProfileStringValue(roleTrait.Profile, "role_name") + roleName, ok := rs.GetProfileStringValue(resource.GetProfile(), "role_name") if !ok { return nil, nil, fmt.Errorf("databricks-connector: failed to get role type from role profile") } @@ -313,12 +305,7 @@ func (r *roleBuilder) Grant(ctx context.Context, principal *v2.Resource, entitle return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can be granted role membership") } - roleTrait, err := rs.GetRoleTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get role trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(roleTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(entitlement.Resource.GetProfile()) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to get parent info from role profile: %w", err) } @@ -394,12 +381,7 @@ func (r *roleBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations. return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can have role membership revoked") } - roleTrait, err := rs.GetRoleTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get role trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(roleTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(entitlement.Resource.GetProfile()) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to get parent info from role profile: %w", err) } diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index d90cba97..253bb3bc 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -37,10 +37,6 @@ func (s *servicePrincipalBuilder) servicePrincipalResource(ctx context.Context, "parent_id": parent.Resource, } - servicePrincipalTraitOptions := []rs.GroupTraitOption{ - rs.WithGroupProfile(profile), - } - // keep the parent resource id, only if the parent resource is account var options []rs.ResourceOption if parent.ResourceType == accountResourceType.Id { @@ -53,12 +49,13 @@ func (s *servicePrincipalBuilder) servicePrincipalResource(ctx context.Context, v2.NonHumanIdentityTrait_NHI_TYPE_APP_REGISTRATION, "databricks.service_principal", )) + options = append(options, rs.WithResourceProfile(profile)) resource, err := rs.NewGroupResource( servicePrincipal.DisplayName, servicePrincipalResourceType, servicePrincipal.ID, - servicePrincipalTraitOptions, + nil, options..., ) @@ -120,12 +117,7 @@ func (s *servicePrincipalBuilder) List(ctx context.Context, parentResourceID *v2 func (s *servicePrincipalBuilder) Entitlements(_ context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { var rv []*v2.Entitlement - groupTrait, err := rs.GetGroupTrait(resource) - if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(groupTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(resource.GetProfile()) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to get parent info from group profile: %w", err) } @@ -135,7 +127,7 @@ func (s *servicePrincipalBuilder) Entitlements(_ context.Context, resource *v2.R workspaceId = parentID } - applicationId, ok := rs.GetProfileStringValue(groupTrait.Profile, "application_id") + applicationId, ok := rs.GetProfileStringValue(resource.GetProfile(), "application_id") if !ok { return nil, nil, fmt.Errorf("databricks-connector: failed to get application_id from service principal profile") } @@ -165,12 +157,7 @@ func (s *servicePrincipalBuilder) Entitlements(_ context.Context, resource *v2.R func (s *servicePrincipalBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { l := ctxzap.Extract(ctx) - groupTrait, err := rs.GetGroupTrait(resource) - if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(groupTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(resource.GetProfile()) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to get parent info from group profile: %w", err) } @@ -180,7 +167,7 @@ func (s *servicePrincipalBuilder) Grants(ctx context.Context, resource *v2.Resou workspaceId = parentID } - applicationId, ok := rs.GetProfileStringValue(groupTrait.Profile, "application_id") + applicationId, ok := rs.GetProfileStringValue(resource.GetProfile(), "application_id") if !ok { return nil, nil, fmt.Errorf("databricks-connector: failed to get application_id from service principal profile") } @@ -239,12 +226,7 @@ func (s *servicePrincipalBuilder) Grant(ctx context.Context, principal *v2.Resou return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can be granted service principal permissions") } - groupTrait, err := rs.GetGroupTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(groupTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(entitlement.Resource.GetProfile()) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to get parent info from group profile: %w", err) } @@ -254,7 +236,7 @@ func (s *servicePrincipalBuilder) Grant(ctx context.Context, principal *v2.Resou workspaceId = parentID } - applicationId, ok := rs.GetProfileStringValue(groupTrait.Profile, "application_id") + applicationId, ok := rs.GetProfileStringValue(entitlement.Resource.GetProfile(), "application_id") if !ok { return nil, fmt.Errorf("databricks-connector: failed to get application_id from service principal profile") } @@ -322,12 +304,7 @@ func (s *servicePrincipalBuilder) Revoke(ctx context.Context, grant *v2.Grant) ( return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can have service principal permissions revoked") } - groupTrait, err := rs.GetGroupTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - parentType, parentID, err := getParentInfoFromProfile(groupTrait.Profile) + parentType, parentID, err := getParentInfoFromProfile(entitlement.Resource.GetProfile()) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to get parent info from group profile: %w", err) } @@ -337,7 +314,7 @@ func (s *servicePrincipalBuilder) Revoke(ctx context.Context, grant *v2.Grant) ( workspaceId = parentID } - applicationId, ok := rs.GetProfileStringValue(groupTrait.Profile, "application_id") + applicationId, ok := rs.GetProfileStringValue(entitlement.Resource.GetProfile(), "application_id") if !ok { return nil, fmt.Errorf("databricks-connector: failed to get application_id from service principal profile") } diff --git a/pkg/connector/users.go b/pkg/connector/users.go index a7ace096..3a1b4afc 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -34,11 +34,9 @@ func (u *userBuilder) userResource(ctx context.Context, user *databricks.User, p ) } - var status v2.UserTrait_Status_Status + resourceStatus := v2.Status_RESOURCE_STATUS_DISABLED if user.Active { - status = v2.UserTrait_Status_STATUS_ENABLED - } else { - status = v2.UserTrait_Status_STATUS_DISABLED + resourceStatus = v2.Status_RESOURCE_STATUS_ENABLED } firstName, lastName := rs.SplitFullName(user.DisplayName) @@ -51,8 +49,6 @@ func (u *userBuilder) userResource(ctx context.Context, user *databricks.User, p } userTraitOptions := []rs.UserTraitOption{ - rs.WithUserProfile(profile), - rs.WithStatus(status), rs.WithUserLogin(user.UserName), rs.WithEmail(primaryEmail, true), } @@ -60,7 +56,7 @@ func (u *userBuilder) userResource(ctx context.Context, user *databricks.User, p userTraitOptions = append(userTraitOptions, emailOptions...) // keep the parent resource id, only if the parent resource is account - var options []rs.ResourceOption + options := []rs.ResourceOption{rs.WithResourceProfile(profile), rs.WithResourceStatus(resourceStatus, "")} if parent.ResourceType == accountResourceType.Id { options = append(options, rs.WithParentResourceID(parent)) } diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 123d1351..b8aff9ca 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -39,10 +39,9 @@ func workspaceResource(_ context.Context, workspace *databricks.Workspace, paren workspace.Name, workspaceResourceType, workspace.DeploymentName, - []rs.GroupTraitOption{ - rs.WithGroupProfile(profile), - }, + nil, rs.WithParentResourceID(parent), + rs.WithResourceProfile(profile), rs.WithAnnotation( &v2.ChildResourceType{ResourceTypeId: roleResourceType.Id}, ), @@ -111,14 +110,9 @@ func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, _ return nil, nil, nil } - groupTrait, err := rs.GetGroupTrait(resource) - if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - workspaceId, ok := rs.GetProfileInt64Value(groupTrait.Profile, "workspace_id") + workspaceId, ok := rs.GetProfileInt64Value(resource.GetProfile(), "workspace_id") if !ok { - return nil, nil, fmt.Errorf("databricks-connector: failed to get workspace ID: %w", err) + return nil, nil, fmt.Errorf("databricks-connector: failed to get workspace ID") } workspace := strconv.Itoa(int(workspaceId)) @@ -195,18 +189,13 @@ func (w *workspaceBuilder) Grant(ctx context.Context, principal *v2.Resource, en return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can be granted workspace membership") } - groupTrait, err := rs.GetGroupTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - workspaceID, ok := rs.GetProfileInt64Value(groupTrait.Profile, "workspace_id") + workspaceID, ok := rs.GetProfileInt64Value(entitlement.Resource.GetProfile(), "workspace_id") if !ok { - return nil, fmt.Errorf("databricks-connector: failed to get workspace ID: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to get workspace ID") } workspace := strconv.Itoa(int(workspaceID)) - _, err = w.client.CreateOrUpdateWorkspaceMember(ctx, workspace, principal.Id.Resource) + _, err := w.client.CreateOrUpdateWorkspaceMember(ctx, workspace, principal.Id.Resource) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to create or update workspace member: %w", err) } @@ -230,18 +219,13 @@ func (w *workspaceBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, fmt.Errorf("databricks-connector: only users, groups and service principals can have workspace membership revoked") } - groupTrait, err := rs.GetGroupTrait(entitlement.Resource) - if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to get group trait: %w", err) - } - - workspaceID, ok := rs.GetProfileInt64Value(groupTrait.Profile, "workspace_id") + workspaceID, ok := rs.GetProfileInt64Value(entitlement.Resource.GetProfile(), "workspace_id") if !ok { - return nil, fmt.Errorf("databricks-connector: failed to get workspace ID: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to get workspace ID") } workspace := strconv.Itoa(int(workspaceID)) - _, err = w.client.RemoveWorkspaceMember(ctx, workspace, principal.Id.Resource) + _, err := w.client.RemoveWorkspaceMember(ctx, workspace, principal.Id.Resource) if err != nil { return nil, fmt.Errorf("databricks-connector: failed to create or update workspace member: %w", err) } From a45e383c64698f8f57869114d344cae38861978e Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 16:20:16 +0000 Subject: [PATCH 05/11] feat(credentials): complete Databricks secret lifecycle Add account-scoped deletion, exact issue/list/delete parity coverage, and workspace-only issuance capability gating per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service-principals.go | 18 ++- pkg/connector/service_principal_secrets.go | 24 ++- .../service_principal_secrets_test.go | 143 +++++++++++++++++- pkg/databricks/client.go | 13 +- pkg/databricks/request.go | 24 +++ 5 files changed, 210 insertions(+), 12 deletions(-) diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index 253bb3bc..4411d819 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -20,10 +20,14 @@ import ( type servicePrincipalBuilder struct { client *databricks.Client resourceType *v2.ResourceType +} + +type credentialIssuingServicePrincipalBuilder struct { + *servicePrincipalBuilder createSecret func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) } -var _ connectorbuilder.CredentialIssuerV2 = (*servicePrincipalBuilder)(nil) +var _ connectorbuilder.CredentialIssuerV2 = (*credentialIssuingServicePrincipalBuilder)(nil) func (s *servicePrincipalBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return servicePrincipalResourceType @@ -374,10 +378,16 @@ func (s *servicePrincipalBuilder) Revoke(ctx context.Context, grant *v2.Grant) ( return nil, nil } -func newServicePrincipalBuilder(client *databricks.Client) *servicePrincipalBuilder { - return &servicePrincipalBuilder{ +func newServicePrincipalBuilder(client *databricks.Client) connectorbuilder.ResourceSyncerV2 { + base := &servicePrincipalBuilder{ client: client, resourceType: servicePrincipalResourceType, - createSecret: client.CreateServicePrincipalSecret, + } + if !client.HasAccountConfiguration() { + return base + } + return &credentialIssuingServicePrincipalBuilder{ + servicePrincipalBuilder: base, + createSecret: client.CreateServicePrincipalSecret, } } diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 6809703d..9d6d6d87 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -18,11 +18,14 @@ import ( ) type servicePrincipalSecretBuilder struct { - client *databricks.Client + client *databricks.Client + deleteSecret func(context.Context, string, string) error } +var _ connectorbuilder.ResourceDeleterV2 = (*servicePrincipalSecretBuilder)(nil) + func newServicePrincipalSecretBuilder(client *databricks.Client) *servicePrincipalSecretBuilder { - return &servicePrincipalSecretBuilder{client: client} + return &servicePrincipalSecretBuilder{client: client, deleteSecret: client.DeleteServicePrincipalSecret} } func (s *servicePrincipalSecretBuilder) ResourceType(context.Context) *v2.ResourceType { @@ -67,7 +70,20 @@ func (*servicePrincipalSecretBuilder) Grants(context.Context, *v2.Resource, reso return nil, nil, nil } -func (s *servicePrincipalBuilder) Issue( +func (s *servicePrincipalSecretBuilder) Delete(ctx context.Context, resourceID, parentResourceID *v2.ResourceId) (annotations.Annotations, error) { + if resourceID == nil || resourceID.GetResourceType() != servicePrincipalSecretResourceType.Id || resourceID.GetResource() == "" { + return nil, fmt.Errorf("databricks-connector: invalid service principal secret resource") + } + if parentResourceID == nil || parentResourceID.GetResourceType() != servicePrincipalResourceType.Id || parentResourceID.GetResource() == "" { + return nil, fmt.Errorf("databricks-connector: invalid service principal parent resource") + } + if err := s.deleteSecret(ctx, parentResourceID.GetResource(), resourceID.GetResource()); err != nil { + return nil, fmt.Errorf("databricks-connector: delete service principal secret: %w", err) + } + return nil, nil +} + +func (s *credentialIssuingServicePrincipalBuilder) Issue( ctx context.Context, input *connectorbuilder.CredentialIssueInput, ) (*connectorbuilder.CredentialIssueOutput, error) { @@ -112,7 +128,7 @@ func (s *servicePrincipalBuilder) Issue( }, nil } -func (*servicePrincipalBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { +func (*credentialIssuingServicePrincipalBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { return v2.CredentialDetailsCredentialIssue_builder{ Options: []*v2.CredentialIssueOptionDescriptor{ v2.CredentialIssueOptionDescriptor_builder{ diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index ffd2f1cb..50f1bf83 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -2,6 +2,13 @@ package connector import ( "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" "testing" "time" @@ -9,14 +16,47 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwk" + "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" ) +type credentialLifecycleConnector struct { + servicePrincipals connectorbuilder.ResourceSyncerV2 + secrets *servicePrincipalSecretBuilder +} + +func (c *credentialLifecycleConnector) Metadata(context.Context) (*v2.ConnectorMetadata, error) { + return &v2.ConnectorMetadata{}, nil +} + +func (c *credentialLifecycleConnector) Validate(context.Context) (annotations.Annotations, error) { + return nil, nil +} + +func (c *credentialLifecycleConnector) ResourceSyncers(context.Context) []connectorbuilder.ResourceSyncerV2 { + return []connectorbuilder.ResourceSyncerV2{c.servicePrincipals, c.secrets} +} + +func newIssueEncryptionConfig(t *testing.T) *v2.EncryptionConfig { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := (&jose.JSONWebKey{Key: privateKey}).Public() + encoded, err := publicKey.MarshalJSON() + require.NoError(t, err) + return v2.EncryptionConfig_builder{ + Provider: jwk.EncryptionProviderJwk, + JwkPublicKeyConfig: v2.EncryptionConfig_JWKPublicKeyConfig_builder{PubKey: encoded}.Build(), + }.Build() +} + func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { identityID := &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"} - builder := &servicePrincipalBuilder{ - resourceType: servicePrincipalResourceType, + builder := &credentialIssuingServicePrincipalBuilder{ + servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, createSecret: func(_ context.Context, servicePrincipalID, lifetime string) (*databricks.ServicePrincipalSecret, error) { require.Equal(t, "sp-123", servicePrincipalID) require.Equal(t, "86400s", lifetime) @@ -53,7 +93,7 @@ func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { } func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { - builder := &servicePrincipalBuilder{} + builder := &credentialIssuingServicePrincipalBuilder{servicePrincipalBuilder: &servicePrincipalBuilder{}} _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: &v2.ResourceId{ ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123", @@ -62,3 +102,100 @@ func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { }.Build()}) require.ErrorContains(t, err, "only OAuth client-secret credentials") } + +func TestServicePrincipalSecretCredentialLifecycle(t *testing.T) { + ctx := context.Background() + secrets := map[string]databricks.ServicePrincipalSecret{} + deleted := false + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.True(t, strings.HasPrefix(r.URL.Path, "/api/2.0/accounts/account-1/servicePrincipals/sp-123/credentials/secrets")) + switch r.Method { + case http.MethodPost: + secret := databricks.ServicePrincipalSecret{ID: "secret-456", Secret: "one-time-secret", Status: "ACTIVE"} + secrets[secret.ID] = secret + require.NoError(t, json.NewEncoder(w).Encode(secret)) + case http.MethodGet: + listed := make([]databricks.ServicePrincipalSecret, 0, len(secrets)) + for _, secret := range secrets { + secret.Secret = "" + listed = append(listed, secret) + } + require.NoError(t, json.NewEncoder(w).Encode(databricks.ListServicePrincipalSecretsResponse{Secrets: listed})) + case http.MethodDelete: + require.Equal(t, "/api/2.0/accounts/account-1/servicePrincipals/sp-123/credentials/secrets/secret-456", r.URL.Path) + delete(secrets, "secret-456") + deleted = true + w.WriteHeader(http.StatusOK) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + client, err := databricks.NewClient(ctx, server.Client(), "workspace.example", serverURL.Host, "account-1", server.URL, &databricks.NoAuth{}) + require.NoError(t, err) + lifecycle := &credentialLifecycleConnector{ + servicePrincipals: newServicePrincipalBuilder(client), + secrets: newServicePrincipalSecretBuilder(client), + } + connector, err := connectorbuilder.NewConnector(ctx, lifecycle) + require.NoError(t, err) + + identityID := v2.ResourceId_builder{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}.Build() + issued, err := connector.IssueCredential(ctx, v2.IssueCredentialRequest_builder{ + IdentityId: identityID, + CredentialOptions: v2.CredentialOptions_builder{ + ClientSecret: &v2.CredentialOptions_ClientSecret{}, + }.Build(), + EncryptionConfigs: []*v2.EncryptionConfig{newIssueEncryptionConfig(t)}, + }.Build()) + require.NoError(t, err) + require.Equal(t, "secret-456", issued.GetSecret().GetId().GetResource()) + require.Equal(t, identityID, issued.GetSecret().GetParentResourceId()) + + listed, _, err := lifecycle.secrets.List(ctx, identityID, resource.SyncOpAttrs{}) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, issued.GetSecret().GetId(), listed[0].GetId()) + require.Equal(t, issued.GetSecret().GetParentResourceId(), listed[0].GetParentResourceId()) + + _, err = connector.DeleteResourceV2(ctx, v2.DeleteResourceV2Request_builder{ + ResourceId: issued.GetSecret().GetId(), ParentResourceId: issued.GetSecret().GetParentResourceId(), + }.Build()) + require.NoError(t, err) + require.True(t, deleted) + listed, _, err = lifecycle.secrets.List(ctx, identityID, resource.SyncOpAttrs{}) + require.NoError(t, err) + require.Empty(t, listed) +} + +func TestCredentialIssuanceCapabilityRequiresAccountConfiguration(t *testing.T) { + ctx := context.Background() + for _, test := range []struct { + name string + accountID string + wantIssue bool + }{{"account", "account-1", true}, {"workspace only", "", false}} { + t.Run(test.name, func(t *testing.T) { + client, err := databricks.NewClient(ctx, http.DefaultClient, "workspace.example", "accounts.example", test.accountID, "https://workspace.example", &databricks.NoAuth{}) + require.NoError(t, err) + lifecycle := &credentialLifecycleConnector{servicePrincipals: newServicePrincipalBuilder(client), secrets: newServicePrincipalSecretBuilder(client)} + connector, err := connectorbuilder.NewConnector(ctx, lifecycle) + require.NoError(t, err) + metadata, err := connector.GetMetadata(ctx, &v2.ConnectorServiceGetMetadataRequest{}) + require.NoError(t, err) + var found bool + for _, resourceCapability := range metadata.GetMetadata().GetCapabilities().GetResourceTypeCapabilities() { + if resourceCapability.GetResourceType().GetId() != servicePrincipalResourceType.Id { + continue + } + for _, capability := range resourceCapability.GetCapabilities() { + found = found || capability == v2.Capability_CAPABILITY_CREDENTIAL_ISSUE + } + } + require.Equal(t, test.wantIssue, found) + }) + } +} diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 083d1dc2..53843c9f 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -32,6 +32,7 @@ const ( accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" accountServicePrincipalSecretsEndpoint = "/api/2.0/accounts/%s/servicePrincipals/%s/credentials/secrets" + accountServicePrincipalSecretEndpoint = accountServicePrincipalSecretsEndpoint + "/%s" ) type Client struct { @@ -68,13 +69,19 @@ func (c *Client) ListServicePrincipalSecrets( u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountServicePrincipalSecretsEndpoint, c.accountId, servicePrincipalID)) var response ListServicePrincipalSecretsResponse params := []Vars{&ServicePrincipalSecretPageVars{PageSize: 100, PageToken: pageToken}} - _, err := c.Get(ctx, u, &response, params...) + _, err := c.GetNoCache(ctx, u, &response, params...) if err != nil { return nil, err } return &response, nil } +func (c *Client) DeleteServicePrincipalSecret(ctx context.Context, servicePrincipalID, secretID string) error { + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountServicePrincipalSecretEndpoint, c.accountId, servicePrincipalID, secretID)) + _, err := c.Delete(ctx, u) + return err +} + func GetAccountHostname(hostname string) string { if strings.HasSuffix(hostname, azureHost) { return "accounts." + azureHost @@ -131,6 +138,10 @@ func (c *Client) IsAccountAPIAvailable() bool { return c.isAccAPIAvailable } +func (c *Client) HasAccountConfiguration() bool { + return c.accountId != "" +} + func (c *Client) UpdateAvailability(accAPI, wsAPI bool) { c.isAccAPIAvailable = accAPI c.isWSAPIAvailable = wsAPI diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index 3ab895cc..f988e709 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -45,6 +45,25 @@ func (c *Client) Get( urlAddress *url.URL, response interface{}, params ...Vars, +) (*v2.RateLimitDescription, error) { + return c.get(ctx, urlAddress, response, nil, params...) +} + +func (c *Client) GetNoCache( + ctx context.Context, + urlAddress *url.URL, + response interface{}, + params ...Vars, +) (*v2.RateLimitDescription, error) { + return c.get(ctx, urlAddress, response, []uhttp.RequestOption{uhttp.WithNoCache()}, params...) +} + +func (c *Client) get( + ctx context.Context, + urlAddress *url.URL, + response interface{}, + requestOptions []uhttp.RequestOption, + params ...Vars, ) (*v2.RateLimitDescription, error) { return c.doRequest( ctx, @@ -52,6 +71,7 @@ func (c *Client) Get( http.MethodGet, nil, response, + requestOptions, params..., ) } @@ -69,6 +89,7 @@ func (c *Client) Put( http.MethodPut, body, response, + nil, params..., ) } @@ -86,6 +107,7 @@ func (c *Client) Post( http.MethodPost, body, response, + nil, params..., ) } @@ -120,6 +142,7 @@ func (c *Client) doRequest( method string, body interface{}, response interface{}, + requestOptions []uhttp.RequestOption, params ...Vars, ) (*v2.RateLimitDescription, error) { // TODO(marcos): Refactor URLs so that we don't have to unescape. @@ -136,6 +159,7 @@ func (c *Client) doRequest( options := []uhttp.RequestOption{ uhttp.WithAcceptJSONHeader(), } + options = append(options, requestOptions...) if body != nil { options = append(options, uhttp.WithJSONBody(body)) } From 1be11cdf7dbb86290e16372f4cbce28133877feb Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 01:27:42 +0000 Subject: [PATCH 06/11] fix(tests): avoid secret-field lint false positive Encode the synthetic Databricks response as a JSON map so gosec does not treat the test fixture type as a marshaled production secret. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index 50f1bf83..dc57a63f 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -113,7 +113,11 @@ func TestServicePrincipalSecretCredentialLifecycle(t *testing.T) { case http.MethodPost: secret := databricks.ServicePrincipalSecret{ID: "secret-456", Secret: "one-time-secret", Status: "ACTIVE"} secrets[secret.ID] = secret - require.NoError(t, json.NewEncoder(w).Encode(secret)) + require.NoError(t, json.NewEncoder(w).Encode(map[string]string{ + "id": secret.ID, + "secret": secret.Secret, + "status": secret.Status, + })) case http.MethodGet: listed := make([]databricks.ServicePrincipalSecret, 0, len(secrets)) for _, secret := range secrets { From ee41f3def2541b8751aa8e7893836d476d77e9e7 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 17:33:52 +0000 Subject: [PATCH 07/11] fix(credentials): adopt released SDK contract Use the v0.20.2 expiry, request ID, resource-mode, and resource-type contract while dropping prototype-only vendored API changes per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 20 +++++---- .../service_principal_secrets_test.go | 22 ++++++---- .../internal/connector/noop_provisioner.go | 4 -- .../pb/c1/connector/v2/resource_grpc.pb.go | 42 +------------------ .../pkg/connectorbuilder/connectorbuilder.go | 31 -------------- .../baton-sdk/pkg/crypto/crypto.go | 29 ------------- .../baton-sdk/pkg/sdk/empty_connector.go | 4 -- 7 files changed, 28 insertions(+), 124 deletions(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 9d6d6d87..7ace16f7 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -97,11 +97,15 @@ func (s *credentialIssuingServicePrincipalBuilder) Issue( } lifetime := "" - if ttl := input.IssuanceConstraints.GetLifetime(); ttl != nil { - if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.GetNanos() != 0 { + if expiresAt := input.ExpiresAt; expiresAt != nil { + if err := expiresAt.CheckValid(); err != nil { return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") } - lifetime = fmt.Sprintf("%ds", ttl.GetSeconds()) + remaining := time.Until(expiresAt.AsTime()) + if remaining <= 0 { + return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") + } + lifetime = fmt.Sprintf("%ds", int64(remaining/time.Second)) } created, err := s.createSecret(ctx, identityID.GetResource(), lifetime) @@ -119,7 +123,8 @@ func (s *credentialIssuingServicePrincipalBuilder) Issue( return nil, err } return &connectorbuilder.CredentialIssueOutput{ - Secret: secret, + Secret: secret, + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, PlaintextData: []*v2.PlaintextData{{ Name: "client_secret", Description: "Databricks OAuth client secret", @@ -133,10 +138,11 @@ func (*credentialIssuingServicePrincipalBuilder) IssueCapabilityDetails(context. Options: []*v2.CredentialIssueOptionDescriptor{ v2.CredentialIssueOptionDescriptor_builder{ Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, - Lifetime: v2.IssuanceLifetimeCapability_builder{ - Min: durationpb.New(time.Second), - Granularity: durationpb.New(time.Second), + Expiry: v2.IssuanceExpiryCapability_builder{ + Min: durationpb.New(time.Second), }.Build(), + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, + SecretResourceTypeId: servicePrincipalSecretResourceType.Id, }.Build(), }, PreferredOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index dc57a63f..7ef82b77 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -20,7 +21,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" ) type credentialLifecycleConnector struct { @@ -59,7 +60,9 @@ func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, createSecret: func(_ context.Context, servicePrincipalID, lifetime string) (*databricks.ServicePrincipalSecret, error) { require.Equal(t, "sp-123", servicePrincipalID) - require.Equal(t, "86400s", lifetime) + seconds, err := strconv.ParseInt(strings.TrimSuffix(lifetime, "s"), 10, 64) + require.NoError(t, err) + require.InDelta(t, 24*time.Hour/time.Second, seconds, 2) return &databricks.ServicePrincipalSecret{ ID: "secret-456", Secret: "one-time-client-secret", @@ -72,10 +75,10 @@ func TestServicePrincipalBuilderIssueClientSecret(t *testing.T) { output, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: identityID, - CredentialOptions: v2.LocalCredentialOptions_builder{ - ClientSecret: &v2.LocalCredentialOptions_ClientSecret{}, + CredentialOptions: v2.CredentialIssueOptions_builder{ + ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, }.Build(), - IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(24 * time.Hour)}.Build(), + ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Hour)), }) require.NoError(t, err) secret, plaintexts := output.Secret, output.PlaintextData @@ -97,8 +100,8 @@ func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: &v2.ResourceId{ ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123", - }, CredentialOptions: v2.LocalCredentialOptions_builder{ - Token: &v2.LocalCredentialOptions_Token{}, + }, CredentialOptions: v2.CredentialIssueOptions_builder{ + Token: &v2.CredentialIssueOptions_Token{}, }.Build()}) require.ErrorContains(t, err, "only OAuth client-secret credentials") } @@ -150,10 +153,11 @@ func TestServicePrincipalSecretCredentialLifecycle(t *testing.T) { identityID := v2.ResourceId_builder{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}.Build() issued, err := connector.IssueCredential(ctx, v2.IssueCredentialRequest_builder{ IdentityId: identityID, - CredentialOptions: v2.CredentialOptions_builder{ - ClientSecret: &v2.CredentialOptions_ClientSecret{}, + CredentialOptions: v2.CredentialIssueOptions_builder{ + ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, }.Build(), EncryptionConfigs: []*v2.EncryptionConfig{newIssueEncryptionConfig(t)}, + RequestId: "request-123", }.Build()) require.NoError(t, err) require.Equal(t, "secret-456", issued.GetSecret().GetId().GetResource()) diff --git a/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go b/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go index 6a6fc8a9..df5acbd5 100644 --- a/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go +++ b/vendor/github.com/conductorone/baton-sdk/internal/connector/noop_provisioner.go @@ -38,10 +38,6 @@ func (n *noopProvisioner) IssueCredential(ctx context.Context, request *v2.Issue return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") } -func (n *noopProvisioner) GetCredentialIssueEligibility(ctx context.Context, request *v2.GetCredentialIssueEligibilityRequest) (*v2.GetCredentialIssueEligibilityResponse, error) { - return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") -} - func (n *noopProvisioner) CreateAccount(ctx context.Context, request *v2.CreateAccountRequest) (*v2.CreateAccountResponse, error) { return nil, status.Error(codes.FailedPrecondition, "provisioning is not enabled") } diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go index 3387b46e..405bf79d 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/resource_grpc.pb.go @@ -557,9 +557,8 @@ var ResourceDeleterService_ServiceDesc = grpc.ServiceDesc{ } const ( - CredentialManagerService_RotateCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/RotateCredential" - CredentialManagerService_IssueCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/IssueCredential" - CredentialManagerService_GetCredentialIssueEligibility_FullMethodName = "/c1.connector.v2.CredentialManagerService/GetCredentialIssueEligibility" + CredentialManagerService_RotateCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/RotateCredential" + CredentialManagerService_IssueCredential_FullMethodName = "/c1.connector.v2.CredentialManagerService/IssueCredential" ) // CredentialManagerServiceClient is the client API for CredentialManagerService service. @@ -574,7 +573,6 @@ type CredentialManagerServiceClient interface { // additional, distinct secret, so an identity can hold multiple coexisting // keys (e.g. cloud service-account key #1 and #2). IssueCredential(ctx context.Context, in *IssueCredentialRequest, opts ...grpc.CallOption) (*IssueCredentialResponse, error) - GetCredentialIssueEligibility(ctx context.Context, in *GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*GetCredentialIssueEligibilityResponse, error) } type credentialManagerServiceClient struct { @@ -605,16 +603,6 @@ func (c *credentialManagerServiceClient) IssueCredential(ctx context.Context, in return out, nil } -func (c *credentialManagerServiceClient) GetCredentialIssueEligibility(ctx context.Context, in *GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*GetCredentialIssueEligibilityResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetCredentialIssueEligibilityResponse) - err := c.cc.Invoke(ctx, CredentialManagerService_GetCredentialIssueEligibility_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - // CredentialManagerServiceServer is the server API for CredentialManagerService service. // All implementations should embed UnimplementedCredentialManagerServiceServer // for forward compatibility. @@ -627,7 +615,6 @@ type CredentialManagerServiceServer interface { // additional, distinct secret, so an identity can hold multiple coexisting // keys (e.g. cloud service-account key #1 and #2). IssueCredential(context.Context, *IssueCredentialRequest) (*IssueCredentialResponse, error) - GetCredentialIssueEligibility(context.Context, *GetCredentialIssueEligibilityRequest) (*GetCredentialIssueEligibilityResponse, error) } // UnimplementedCredentialManagerServiceServer should be embedded to have @@ -643,9 +630,6 @@ func (UnimplementedCredentialManagerServiceServer) RotateCredential(context.Cont func (UnimplementedCredentialManagerServiceServer) IssueCredential(context.Context, *IssueCredentialRequest) (*IssueCredentialResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method IssueCredential not implemented") } -func (UnimplementedCredentialManagerServiceServer) GetCredentialIssueEligibility(context.Context, *GetCredentialIssueEligibilityRequest) (*GetCredentialIssueEligibilityResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetCredentialIssueEligibility not implemented") -} func (UnimplementedCredentialManagerServiceServer) testEmbeddedByValue() {} // UnsafeCredentialManagerServiceServer may be embedded to opt out of forward compatibility for this service. @@ -702,24 +686,6 @@ func _CredentialManagerService_IssueCredential_Handler(srv interface{}, ctx cont return interceptor(ctx, in, info, handler) } -func _CredentialManagerService_GetCredentialIssueEligibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetCredentialIssueEligibilityRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(CredentialManagerServiceServer).GetCredentialIssueEligibility(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: CredentialManagerService_GetCredentialIssueEligibility_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CredentialManagerServiceServer).GetCredentialIssueEligibility(ctx, req.(*GetCredentialIssueEligibilityRequest)) - } - return interceptor(ctx, in, info, handler) -} - // CredentialManagerService_ServiceDesc is the grpc.ServiceDesc for CredentialManagerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -735,10 +701,6 @@ var CredentialManagerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "IssueCredential", Handler: _CredentialManagerService_IssueCredential_Handler, }, - { - MethodName: "GetCredentialIssueEligibility", - Handler: _CredentialManagerService_GetCredentialIssueEligibility_Handler, - }, }, Streams: []grpc.StreamDesc{}, Metadata: "c1/connector/v2/resource.proto", diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go index 74120a51..a64d4b58 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/connectorbuilder/connectorbuilder.go @@ -516,26 +516,6 @@ func validateCapabilityDetails(_ context.Context, credDetails *v2.CredentialDeta } } - if credDetails.HasCapabilityCredentialIssue() { - issue := credDetails.GetCapabilityCredentialIssue() - if issue.GetPreferredOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { - return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not set") - } - seen := make(map[v2.CapabilityDetailCredentialOption]struct{}, len(issue.GetOptions())) - for _, descriptor := range issue.GetOptions() { - if descriptor == nil || descriptor.GetOption() == v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_UNSPECIFIED { - return status.Error(codes.InvalidArgument, "error: credential issue option descriptor is invalid") - } - if _, exists := seen[descriptor.GetOption()]; exists { - return status.Errorf(codes.InvalidArgument, "error: duplicate credential issue option %s", descriptor.GetOption()) - } - seen[descriptor.GetOption()] = struct{}{} - } - if _, ok := seen[issue.GetPreferredOption()]; !ok { - return status.Error(codes.InvalidArgument, "error: preferred credential issue option is not part of the supported options") - } - } - return nil } @@ -655,17 +635,6 @@ func getCredentialDetails(ctx context.Context, b *builder) (*v2.CredentialDetail break // Only need one credential manager's details } - // Check for credential issuance capability details - for _, ci := range b.credentialIssuers { - credentialIssueCapabilityDetails, _, err := ci.IssueCapabilityDetails(ctx) - if err != nil { - l.Error("error: getting credential issuance details", zap.Error(err)) - return nil, fmt.Errorf("error: getting credential issuance details: %w", err) - } - rv.SetCapabilityCredentialIssue(credentialIssueCapabilityDetails) - break // Only need one credential issuer's details - } - err := validateCapabilityDetails(ctx, rv) if err != nil { return nil, fmt.Errorf("error: validating capability details: %w", err) diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go index 5c70d60a..d1fceab3 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/crypto/crypto.go @@ -55,20 +55,6 @@ func (pkem *EncryptionManager) Encrypt(ctx context.Context, cred *v2.PlaintextDa } func NewEncryptionManager(co *v2.CredentialOptions, ec []*v2.EncryptionConfig) (*EncryptionManager, error) { - for i, config := range ec { - if config == nil { - return nil, status.Errorf(codes.InvalidArgument, "encryption config %d is empty", i) - } - provider, err := providers.GetEncryptorForConfig(context.Background(), config) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid encryption config %d: %v", i, err) - } - if validator, ok := provider.(providers.EncryptionConfigValidator); ok { - if err := validator.ValidateConfig(context.Background(), config); err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid encryption config %d: %v", i, err) - } - } - } em := &EncryptionManager{ opts: co, configs: ec, @@ -140,21 +126,6 @@ func ConvertCredentialOptions(ctx context.Context, clientSecret *jose.JSONWebKey localOpts.SetSso(v2.LocalCredentialOptions_SSO_builder{ SsoProvider: opts.GetSso().GetSsoProvider(), }.Build()) - case v2.CredentialOptions_ApiKey_case: - localOpts.SetApiKey(v2.LocalCredentialOptions_ApiKey_builder{ - Scopes: opts.GetApiKey().GetScopes(), - }.Build()) - case v2.CredentialOptions_Keypair_case: - localOpts.SetKeypair(v2.LocalCredentialOptions_Keypair_builder{ - Profile: opts.GetKeypair().GetProfile(), - }.Build()) - case v2.CredentialOptions_Token_case: - localOpts.SetToken(v2.LocalCredentialOptions_Token_builder{ - Scopes: opts.GetToken().GetScopes(), - Audience: opts.GetToken().GetAudience(), - }.Build()) - case v2.CredentialOptions_ClientSecret_case: - localOpts.SetClientSecret(&v2.LocalCredentialOptions_ClientSecret{}) case v2.CredentialOptions_EncryptedPassword_case: default: return nil, status.Error(codes.InvalidArgument, "invalid credential options") diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go index 471d426e..1808659a 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/empty_connector.go @@ -132,10 +132,6 @@ func (n *emptyConnector) IssueCredential(ctx context.Context, request *v2.IssueC return nil, status.Errorf(codes.Unimplemented, "empty connector") } -func (n *emptyConnector) GetCredentialIssueEligibility(ctx context.Context, request *v2.GetCredentialIssueEligibilityRequest, opts ...grpc.CallOption) (*v2.GetCredentialIssueEligibilityResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "empty connector") -} - func (n *emptyConnector) CreateResource(ctx context.Context, request *v2.CreateResourceRequest, opts ...grpc.CallOption) (*v2.CreateResourceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "empty connector") } From b707d6d875049807e593948c5bb7eb525863aa2c Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 20:48:15 +0000 Subject: [PATCH 08/11] fix(credentials): require explicit secret sync opt-in Mark service-principal secrets OptInRequired while preserving entitlement skipping and account-scoped issuance registration per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/resource_types.go | 5 +++- .../service_principal_secrets_test.go | 23 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index ef207c79..7f87f1d5 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -33,7 +33,10 @@ var ( Id: "service_principal_secret", DisplayName: "Service Principal Secret", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, - Annotations: annotations.New(&v2.SkipEntitlementsAndGrants{}), + Annotations: annotations.New( + &v2.SkipEntitlementsAndGrants{}, + &v2.OptInRequired{}, + ), } // The role resource type is for all static roles and entitlements available in API. diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index 7ef82b77..0467e049 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -194,16 +194,25 @@ func TestCredentialIssuanceCapabilityRequiresAccountConfiguration(t *testing.T) require.NoError(t, err) metadata, err := connector.GetMetadata(ctx, &v2.ConnectorServiceGetMetadataRequest{}) require.NoError(t, err) - var found bool + var foundIssue bool + var foundSecret bool for _, resourceCapability := range metadata.GetMetadata().GetCapabilities().GetResourceTypeCapabilities() { - if resourceCapability.GetResourceType().GetId() != servicePrincipalResourceType.Id { - continue - } - for _, capability := range resourceCapability.GetCapabilities() { - found = found || capability == v2.Capability_CAPABILITY_CREDENTIAL_ISSUE + switch resourceCapability.GetResourceType().GetId() { + case servicePrincipalResourceType.Id: + for _, capability := range resourceCapability.GetCapabilities() { + foundIssue = foundIssue || capability == v2.Capability_CAPABILITY_CREDENTIAL_ISSUE + } + case servicePrincipalSecretResourceType.Id: + foundSecret = true + require.True(t, resourceCapability.GetOptInRequired()) + annos := annotations.Annotations(resourceCapability.GetResourceType().GetAnnotations()) + require.True(t, annos.Contains(&v2.SkipEntitlementsAndGrants{})) } } - require.Equal(t, test.wantIssue, found) + require.True(t, foundSecret) + require.Equal(t, test.wantIssue, foundIssue) + _, isIssuer := lifecycle.servicePrincipals.(connectorbuilder.CredentialIssuerV2) + require.Equal(t, test.wantIssue, isIssuer) }) } } From 98143f58df07700a3a9d6d48da4797d4ad5da202 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 21:17:28 +0000 Subject: [PATCH 09/11] fix(credentials): harden secret lifecycle edges Skip account-only listing without account configuration, make provider 404 deletion idempotent, and reject sub-second expiry before mutation per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 13 +++++- .../service_principal_secrets_test.go | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 7ace16f7..99231dba 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -40,6 +40,9 @@ func (s *servicePrincipalSecretBuilder) List( if parentResourceID == nil { return nil, nil, nil } + if !s.client.HasAccountConfiguration() { + return nil, &resource.SyncOpResults{}, nil + } response, err := s.client.ListServicePrincipalSecrets(ctx, parentResourceID.GetResource(), attr.PageToken.Token) if err != nil { @@ -78,6 +81,10 @@ func (s *servicePrincipalSecretBuilder) Delete(ctx context.Context, resourceID, return nil, fmt.Errorf("databricks-connector: invalid service principal parent resource") } if err := s.deleteSecret(ctx, parentResourceID.GetResource(), resourceID.GetResource()); err != nil { + var apiErr *databricks.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return nil, nil + } return nil, fmt.Errorf("databricks-connector: delete service principal secret: %w", err) } return nil, nil @@ -105,7 +112,11 @@ func (s *credentialIssuingServicePrincipalBuilder) Issue( if remaining <= 0 { return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") } - lifetime = fmt.Sprintf("%ds", int64(remaining/time.Second)) + seconds := int64(remaining / time.Second) + if seconds < 1 { + return nil, fmt.Errorf("databricks-connector: client-secret lifetime is below provider minimum") + } + lifetime = fmt.Sprintf("%ds", seconds) } created, err := s.createSecret(ctx, identityID.GetResource(), lifetime) diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index 0467e049..e73a7742 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -106,6 +106,47 @@ func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { require.ErrorContains(t, err, "only OAuth client-secret credentials") } +func TestServicePrincipalBuilderIssueRejectsSubSecondExpiryBeforeCreation(t *testing.T) { + created := false + builder := &credentialIssuingServicePrincipalBuilder{ + servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, + createSecret: func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) { + created = true + return nil, nil + }, + } + _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + IdentityID: &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, + CredentialOptions: v2.CredentialIssueOptions_builder{ + ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, + }.Build(), + ExpiresAt: timestamppb.New(time.Now().Add(500 * time.Millisecond)), + }) + require.ErrorContains(t, err, "below provider minimum") + require.False(t, created) +} + +func TestServicePrincipalSecretListSkipsWorkspaceOnlyConfiguration(t *testing.T) { + client, err := databricks.NewClient(context.Background(), http.DefaultClient, "workspace.example", "accounts.invalid", "", "https://workspace.example", &databricks.NoAuth{}) + require.NoError(t, err) + builder := newServicePrincipalSecretBuilder(client) + listed, result, err := builder.List(context.Background(), &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, resource.SyncOpAttrs{}) + require.NoError(t, err) + require.Empty(t, listed) + require.NotNil(t, result) +} + +func TestServicePrincipalSecretDeleteIsIdempotentOnNotFound(t *testing.T) { + builder := &servicePrincipalSecretBuilder{deleteSecret: func(context.Context, string, string) error { + return &databricks.APIError{StatusCode: http.StatusNotFound} + }} + _, err := builder.Delete(context.Background(), + &v2.ResourceId{ResourceType: servicePrincipalSecretResourceType.Id, Resource: "secret-456"}, + &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, + ) + require.NoError(t, err) +} + func TestServicePrincipalSecretCredentialLifecycle(t *testing.T) { ctx := context.Background() secrets := map[string]databricks.ServicePrincipalSecret{} From 30fd9f574af2f65ac09e888048660e90344013fd Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 21:19:39 +0000 Subject: [PATCH 10/11] fix(credentials): report confirmed secret absence Emit ResourceDoesNotExist for provider 404 and ceil positive expiry to the provider one-second minimum per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 8 +++----- .../service_principal_secrets_test.go | 18 +++++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 99231dba..11859459 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "net/http" "time" @@ -83,7 +84,7 @@ func (s *servicePrincipalSecretBuilder) Delete(ctx context.Context, resourceID, if err := s.deleteSecret(ctx, parentResourceID.GetResource(), resourceID.GetResource()); err != nil { var apiErr *databricks.APIError if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { - return nil, nil + return annotations.New(&v2.ResourceDoesNotExist{}), nil } return nil, fmt.Errorf("databricks-connector: delete service principal secret: %w", err) } @@ -112,10 +113,7 @@ func (s *credentialIssuingServicePrincipalBuilder) Issue( if remaining <= 0 { return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") } - seconds := int64(remaining / time.Second) - if seconds < 1 { - return nil, fmt.Errorf("databricks-connector: client-secret lifetime is below provider minimum") - } + seconds := max(int64(math.Ceil(remaining.Seconds())), 1) lifetime = fmt.Sprintf("%ds", seconds) } diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index e73a7742..cd5d2ead 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -106,24 +106,23 @@ func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { require.ErrorContains(t, err, "only OAuth client-secret credentials") } -func TestServicePrincipalBuilderIssueRejectsSubSecondExpiryBeforeCreation(t *testing.T) { - created := false +func TestServicePrincipalBuilderIssueRoundsSubSecondExpiryToProviderMinimum(t *testing.T) { builder := &credentialIssuingServicePrincipalBuilder{ servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, - createSecret: func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) { - created = true - return nil, nil + createSecret: func(_ context.Context, _, lifetime string) (*databricks.ServicePrincipalSecret, error) { + require.Equal(t, "1s", lifetime) + return &databricks.ServicePrincipalSecret{ID: "secret-456", Secret: "one-time-secret"}, nil }, } - _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + output, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, CredentialOptions: v2.CredentialIssueOptions_builder{ ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, }.Build(), ExpiresAt: timestamppb.New(time.Now().Add(500 * time.Millisecond)), }) - require.ErrorContains(t, err, "below provider minimum") - require.False(t, created) + require.NoError(t, err) + require.Equal(t, "secret-456", output.Secret.GetId().GetResource()) } func TestServicePrincipalSecretListSkipsWorkspaceOnlyConfiguration(t *testing.T) { @@ -140,11 +139,12 @@ func TestServicePrincipalSecretDeleteIsIdempotentOnNotFound(t *testing.T) { builder := &servicePrincipalSecretBuilder{deleteSecret: func(context.Context, string, string) error { return &databricks.APIError{StatusCode: http.StatusNotFound} }} - _, err := builder.Delete(context.Background(), + annos, err := builder.Delete(context.Background(), &v2.ResourceId{ResourceType: servicePrincipalSecretResourceType.Id, Resource: "secret-456"}, &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, ) require.NoError(t, err) + require.True(t, annos.Contains(&v2.ResourceDoesNotExist{})) } func TestServicePrincipalSecretCredentialLifecycle(t *testing.T) { From 4486cc5712de921672c83c88bfa800fe09bcbaaf Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 21:21:54 +0000 Subject: [PATCH 11/11] fix(credentials): enforce absolute expiry bound Reject sub-second remaining validity before provider mutation so issued credentials cannot exceed approved ExpiresAt per D-NHI-012. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/service_principal_secrets.go | 6 ++++-- pkg/connector/service_principal_secrets_test.go | 15 ++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/connector/service_principal_secrets.go b/pkg/connector/service_principal_secrets.go index 11859459..36d3a0e4 100644 --- a/pkg/connector/service_principal_secrets.go +++ b/pkg/connector/service_principal_secrets.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "net/http" "time" @@ -113,7 +112,10 @@ func (s *credentialIssuingServicePrincipalBuilder) Issue( if remaining <= 0 { return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") } - seconds := max(int64(math.Ceil(remaining.Seconds())), 1) + seconds := int64(remaining / time.Second) + if seconds < 1 { + return nil, fmt.Errorf("databricks-connector: client-secret lifetime is below provider minimum") + } lifetime = fmt.Sprintf("%ds", seconds) } diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go index cd5d2ead..dd5eccd8 100644 --- a/pkg/connector/service_principal_secrets_test.go +++ b/pkg/connector/service_principal_secrets_test.go @@ -106,23 +106,24 @@ func TestServicePrincipalBuilderIssueRejectsTokenArm(t *testing.T) { require.ErrorContains(t, err, "only OAuth client-secret credentials") } -func TestServicePrincipalBuilderIssueRoundsSubSecondExpiryToProviderMinimum(t *testing.T) { +func TestServicePrincipalBuilderIssueRejectsSubSecondExpiryBeforeCreation(t *testing.T) { + created := false builder := &credentialIssuingServicePrincipalBuilder{ servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, - createSecret: func(_ context.Context, _, lifetime string) (*databricks.ServicePrincipalSecret, error) { - require.Equal(t, "1s", lifetime) - return &databricks.ServicePrincipalSecret{ID: "secret-456", Secret: "one-time-secret"}, nil + createSecret: func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) { + created = true + return nil, nil }, } - output, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: "sp-123"}, CredentialOptions: v2.CredentialIssueOptions_builder{ ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, }.Build(), ExpiresAt: timestamppb.New(time.Now().Add(500 * time.Millisecond)), }) - require.NoError(t, err) - require.Equal(t, "secret-456", output.Secret.GetId().GetResource()) + require.ErrorContains(t, err, "below provider minimum") + require.False(t, created) } func TestServicePrincipalSecretListSkipsWorkspaceOnlyConfiguration(t *testing.T) {