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/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/resource_types.go b/pkg/connector/resource_types.go index 748bdbef..7f87f1d5 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,17 @@ 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{}, + &v2.OptInRequired{}, + ), } // The role resource type is for all static roles and entitlements available in API. 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 6cc0dd0b..4411d819 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" @@ -21,6 +22,13 @@ type servicePrincipalBuilder struct { resourceType *v2.ResourceType } +type credentialIssuingServicePrincipalBuilder struct { + *servicePrincipalBuilder + createSecret func(context.Context, string, string) (*databricks.ServicePrincipalSecret, error) +} + +var _ connectorbuilder.CredentialIssuerV2 = (*credentialIssuingServicePrincipalBuilder)(nil) + func (s *servicePrincipalBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return servicePrincipalResourceType } @@ -33,10 +41,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 { @@ -49,12 +53,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..., ) @@ -116,12 +121,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) } @@ -131,7 +131,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") } @@ -161,12 +161,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) } @@ -176,7 +171,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") } @@ -235,12 +230,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) } @@ -250,7 +240,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") } @@ -318,12 +308,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) } @@ -333,7 +318,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") } @@ -393,9 +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, } + 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 new file mode 100644 index 00000000..36d3a0e4 --- /dev/null +++ b/pkg/connector/service_principal_secrets.go @@ -0,0 +1,199 @@ +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 { + 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, deleteSecret: client.DeleteServicePrincipalSecret} +} + +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 + } + if !s.client.HasAccountConfiguration() { + return nil, &resource.SyncOpResults{}, nil + } + + 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) + } + + 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 *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 { + var apiErr *databricks.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return annotations.New(&v2.ResourceDoesNotExist{}), 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) { + identityID := input.IdentityID + if identityID == nil || identityID.GetResourceType() != servicePrincipalResourceType.Id { + return nil, fmt.Errorf("databricks-connector: invalid service principal identity") + } + clientSecret := input.CredentialOptions.GetClientSecret() + if clientSecret == nil { + return nil, fmt.Errorf("databricks-connector: only OAuth client-secret credentials are supported") + } + + lifetime := "" + if expiresAt := input.ExpiresAt; expiresAt != nil { + if err := expiresAt.CheckValid(); err != nil { + return nil, fmt.Errorf("databricks-connector: invalid client-secret lifetime") + } + remaining := time.Until(expiresAt.AsTime()) + 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") + } + lifetime = fmt.Sprintf("%ds", seconds) + } + + created, err := s.createSecret(ctx, identityID.GetResource(), lifetime) + if err != nil { + return nil, fmt.Errorf("databricks-connector: create service principal secret: %w", err) + } + if created.Secret == "" { + 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, err + } + return &connectorbuilder.CredentialIssueOutput{ + Secret: secret, + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, + PlaintextData: []*v2.PlaintextData{{ + Name: "client_secret", + Description: "Databricks OAuth client secret", + Bytes: plaintext, + }}, + }, nil +} + +func (*credentialIssuingServicePrincipalBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { + return v2.CredentialDetailsCredentialIssue_builder{ + Options: []*v2.CredentialIssueOptionDescriptor{ + v2.CredentialIssueOptionDescriptor_builder{ + Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_CLIENT_SECRET, + 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, + }.Build(), 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"), + } + 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) + } + resourceOptions = append(resourceOptions, resource.WithResourceCreatedAt(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, + resourceOptions..., + ) +} diff --git a/pkg/connector/service_principal_secrets_test.go b/pkg/connector/service_principal_secrets_test.go new file mode 100644 index 00000000..dd5eccd8 --- /dev/null +++ b/pkg/connector/service_principal_secrets_test.go @@ -0,0 +1,260 @@ +package connector + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "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/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/timestamppb" +) + +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 := &credentialIssuingServicePrincipalBuilder{ + servicePrincipalBuilder: &servicePrincipalBuilder{resourceType: servicePrincipalResourceType}, + createSecret: func(_ context.Context, servicePrincipalID, lifetime string) (*databricks.ServicePrincipalSecret, error) { + require.Equal(t, "sp-123", servicePrincipalID) + 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", + Status: "ACTIVE", + CreateTime: "2026-07-21T00:00:00.000Z", + ExpireTime: "2026-07-22T00:00:00.000Z", + }, nil + }, + } + + output, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: v2.CredentialIssueOptions_builder{ + ClientSecret: &v2.CredentialIssueOptions_ClientSecret{}, + }.Build(), + ExpiresAt: timestamppb.New(time.Now().Add(24 * time.Hour)), + }) + 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()) + 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 := &credentialIssuingServicePrincipalBuilder{servicePrincipalBuilder: &servicePrincipalBuilder{}} + _, err := builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: "sp-123", + }, CredentialOptions: v2.CredentialIssueOptions_builder{ + Token: &v2.CredentialIssueOptions_Token{}, + }.Build()}) + 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} + }} + 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) { + 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(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 { + 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.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()) + 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 foundIssue bool + var foundSecret bool + for _, resourceCapability := range metadata.GetMetadata().GetCapabilities().GetResourceTypeCapabilities() { + 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.True(t, foundSecret) + require.Equal(t, test.wantIssue, foundIssue) + _, isIssuer := lifecycle.servicePrincipals.(connectorbuilder.CredentialIssuerV2) + require.Equal(t, test.wantIssue, isIssuer) + }) + } +} 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) } diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 3ae7d092..53843c9f 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -29,8 +29,10 @@ 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" + accountServicePrincipalSecretEndpoint = accountServicePrincipalSecretsEndpoint + "/%s" ) type Client struct { @@ -45,6 +47,41 @@ 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.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 @@ -101,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/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..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)) } @@ -170,7 +194,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"`