diff --git a/baton_capabilities.json b/baton_capabilities.json index d9f01eed..fe0292dd 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -68,6 +68,29 @@ "permissions": {}, "optInRequired": true }, + { + "resourceType": { + "id": "named_key_pair", + "displayName": "Named Key Pair", + "traits": [ + "TRAIT_SECRET" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC", + "CAPABILITY_RESOURCE_DELETE" + ], + "permissions": {}, + "optInRequired": true + }, { "resourceType": { "id": "rsa_public_key", @@ -133,16 +156,45 @@ "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], - "permissions": {} + "permissions": {}, + "credentialIssue": { + "options": [ + { + "option": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR", + "keyProfiles": [ + { + "kty": "RSA", + "rsaModulusBits": 2048 + }, + { + "kty": "RSA", + "rsaModulusBits": 3072 + }, + { + "kty": "RSA", + "rsaModulusBits": 4096 + } + ], + "expiry": { + "min": "86400s" + }, + "resourceMode": "CREDENTIAL_RESOURCE_MODE_DISCOVERABLE", + "secretResourceTypeId": "named_key_pair" + } + ], + "preferredOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR" + } } ], "connectorCapabilities": [ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], "credentialDetails": { "capabilityAccountProvisioning": { diff --git a/docs/connector.mdx b/docs/connector.mdx index 725caa81..64c68372 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -31,6 +31,12 @@ The Snowflake connector supports [account provisioning](/product/admin/account-p [This connector can sync secrets](/product/admin/inventory) and display them on the **Inventory** page. +When **Sync secrets** is enabled, the connector also inventories Snowflake +named key pairs and can issue a new named RSA key pair for `SERVICE` and +`LEGACY_SERVICE` users. C1 encrypts the one-time private key at the connector +boundary. Supported RSA modulus sizes are 2048, 3072, and 4096 bits. An optional +credential lifetime must be a positive whole number of days. + ## Gather Snowflake credentials Configuring the connector requires you to pass in credentials generated in Snowflake. Gather these credentials before you move on. diff --git a/go.mod b/go.mod index fae48130..6e7b2091 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,11 @@ require ( google.golang.org/grpc v1.81.0 ) -require golang.org/x/oauth2 v0.36.0 +require ( + github.com/segmentio/ksuid v1.0.4 + golang.org/x/oauth2 v0.36.0 + google.golang.org/protobuf v1.36.11 +) require ( filippo.io/age v1.3.1 // indirect @@ -101,7 +105,6 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/segmentio/ksuid v1.0.4 // indirect github.com/shirou/gopsutil/v4 v4.26.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -137,7 +140,6 @@ require ( golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f15567fa..79b05eb4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -37,6 +37,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso builders, newSecretBuilder(d.Client), newRsaBuilder(d.Client), + newNamedKeyPairBuilder(d.Client), ) } diff --git a/pkg/connector/connector_capabilities_test.go b/pkg/connector/connector_capabilities_test.go new file mode 100644 index 00000000..3e99eee0 --- /dev/null +++ b/pkg/connector/connector_capabilities_test.go @@ -0,0 +1,67 @@ +package connector + +import ( + "context" + "slices" + "testing" + + 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-snowflake/pkg/snowflake" + "github.com/stretchr/testify/require" +) + +type capabilityProvider interface { + GetCapabilities(context.Context) (*v2.ConnectorCapabilities, error) +} + +func connectorCapabilities(t *testing.T, syncSecrets bool) map[string]*v2.ResourceTypeCapability { + t.Helper() + server, err := connectorbuilder.NewConnector(context.Background(), &Connector{ + Client: &snowflake.Client{}, + SyncSecrets: syncSecrets, + }) + require.NoError(t, err) + provider, ok := server.(capabilityProvider) + require.True(t, ok) + capabilities, err := provider.GetCapabilities(context.Background()) + require.NoError(t, err) + + byID := make(map[string]*v2.ResourceTypeCapability, len(capabilities.GetResourceTypeCapabilities())) + for _, capability := range capabilities.GetResourceTypeCapabilities() { + byID[capability.GetResourceType().GetId()] = capability + } + return byID +} + +func TestNamedKeyPairCapabilitiesAreOptInAndLifecycleComplete(t *testing.T) { + capabilities := connectorCapabilities(t, true) + namedKeyPair, ok := capabilities[namedKeyPairResourceType.Id] + require.True(t, ok) + require.True(t, namedKeyPair.GetOptInRequired()) + require.True(t, slices.Contains(namedKeyPair.GetCapabilities(), v2.Capability_CAPABILITY_SYNC)) + require.True(t, slices.Contains(namedKeyPair.GetCapabilities(), v2.Capability_CAPABILITY_RESOURCE_DELETE)) + require.False(t, slices.Contains(namedKeyPair.GetCapabilities(), v2.Capability_CAPABILITY_CREDENTIAL_ISSUE)) + annos := annotations.Annotations(namedKeyPair.GetResourceType().GetAnnotations()) + require.True(t, annos.Contains(&v2.SkipEntitlementsAndGrants{})) + + user := capabilities[userResourceType.Id] + require.NotNil(t, user) + require.True(t, slices.Contains(user.GetCapabilities(), v2.Capability_CAPABILITY_CREDENTIAL_ISSUE)) + require.NotNil(t, user.GetCredentialIssue()) + require.Len(t, user.GetCredentialIssue().GetOptions(), 1) + require.Equal(t, namedKeyPairResourceType.Id, user.GetCredentialIssue().GetOptions()[0].GetSecretResourceTypeId()) + require.Equal(t, v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, user.GetCredentialIssue().GetOptions()[0].GetResourceMode()) +} + +func TestNamedKeyPairCapabilitiesAreAbsentWithoutSecretSync(t *testing.T) { + capabilities := connectorCapabilities(t, false) + _, ok := capabilities[namedKeyPairResourceType.Id] + require.False(t, ok) + + user := capabilities[userResourceType.Id] + require.NotNil(t, user) + require.False(t, slices.Contains(user.GetCapabilities(), v2.Capability_CAPABILITY_CREDENTIAL_ISSUE)) + require.Nil(t, user.GetCredentialIssue()) +} diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go new file mode 100644 index 00000000..c92848f0 --- /dev/null +++ b/pkg/connector/named_key_pairs.go @@ -0,0 +1,263 @@ +package connector + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "strings" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-snowflake/pkg/snowflake" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/segmentio/ksuid" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/durationpb" +) + +type namedKeyPairBuilder struct { + listKeyPairs func(context.Context, string) ([]snowflake.NamedKeyPair, error) + removeKeyPair func(context.Context, string, string) error +} + +const ( + snowflakeServiceUserType = "SERVICE" + snowflakeLegacyServiceUserType = "LEGACY_SERVICE" + snowflakeRSAKeyType = "RSA" +) + +func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { + return &namedKeyPairBuilder{ + listKeyPairs: client.ListUserKeyPairs, + removeKeyPair: client.RemoveUserKeyPair, + } +} + +func (*namedKeyPairBuilder) ResourceType(context.Context) *v2.ResourceType { + return namedKeyPairResourceType +} + +func (b *namedKeyPairBuilder) List(ctx context.Context, parent *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if parent == nil || parent.GetResourceType() != userResourceType.Id { + return nil, nil, nil + } + keyPairs, err := b.listKeyPairs(ctx, parent.GetResource()) + if err != nil { + if isUnprocessableEntityError(err) { + ctxzap.Extract(ctx).Debug("ListUserKeyPairs unavailable", zap.String("username", parent.GetResource()), zap.Error(err)) + return nil, nil, nil + } + return nil, nil, fmt.Errorf("baton-snowflake: list named key pairs: %w", err) + } + resources := make([]*v2.Resource, 0, len(keyPairs)) + for i := range keyPairs { + resource, err := namedKeyPairResource(parent, &keyPairs[i]) + if err != nil { + return nil, nil, err + } + resources = append(resources, resource) + } + return resources, nil, nil +} + +func (*namedKeyPairBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +func (*namedKeyPairBuilder) Grants(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +type credentialIssuingUserBuilder struct { + *userBuilder + getUser func(context.Context, string) (*snowflake.User, error) + addKeyPair func(context.Context, string, string, string, int) error + newKeyName func() string + now func() time.Time +} + +var _ connectorbuilder.CredentialIssuerV2 = (*credentialIssuingUserBuilder)(nil) +var _ connectorbuilder.ResourceDeleterV2 = (*namedKeyPairBuilder)(nil) + +func newCredentialIssuingUserBuilder(base *userBuilder) *credentialIssuingUserBuilder { + return &credentialIssuingUserBuilder{ + userBuilder: base, + getUser: func(ctx context.Context, username string) (*snowflake.User, error) { + user, _, err := base.client.GetUser(ctx, nil, username) + return user, err + }, + addKeyPair: base.client.AddUserKeyPair, + newKeyName: func() string { return "c1_" + ksuid.New().String() }, + now: time.Now, + } +} + +func (b *credentialIssuingUserBuilder) Issue( + ctx context.Context, + input *connectorbuilder.CredentialIssueInput, +) (*connectorbuilder.CredentialIssueOutput, error) { + identityID := input.IdentityID + if identityID == nil || identityID.GetResourceType() != userResourceType.Id { + return nil, fmt.Errorf("baton-snowflake: invalid service user identity") + } + user, err := b.getUser(ctx, identityID.GetResource()) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: get credential target: %w", err) + } + if user.Type != snowflakeServiceUserType && user.Type != snowflakeLegacyServiceUserType { + return nil, fmt.Errorf("baton-snowflake: key pairs may only be issued for service users") + } + + if input.CredentialOptions == nil { + return nil, fmt.Errorf("baton-snowflake: credential options are required") + } + keypair := input.CredentialOptions.GetKeypair() + if keypair == nil { + return nil, fmt.Errorf("baton-snowflake: only keypair credentials are supported") + } + if keypair.GetProfile().GetKty() != snowflakeRSAKeyType { + return nil, fmt.Errorf("baton-snowflake: only RSA key pairs are supported") + } + bits := int(keypair.GetProfile().GetRsaModulusBits()) + if bits != 2048 && bits != 3072 && bits != 4096 { + return nil, fmt.Errorf("baton-snowflake: unsupported RSA key size %d", bits) + } + + now := b.now().UTC() + daysToExpiry := 0 + if expiresAt := input.ExpiresAt; expiresAt != nil { + if err := expiresAt.CheckValid(); err != nil { + return nil, fmt.Errorf("baton-snowflake: keypair expiry is invalid: %w", err) + } + remaining := expiresAt.AsTime().Sub(now) + if remaining <= 0 { + return nil, fmt.Errorf("baton-snowflake: keypair expiry must be in the future") + } + // Snowflake accepts only whole DAYS_TO_EXPIRY values. Round up so a + // caller's requested expiry is never shortened by transport latency. + daysToExpiry = int((remaining + 24*time.Hour - 1) / (24 * time.Hour)) + } + + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: generate RSA key: %w", err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: marshal private key: %w", err) + } + privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}) + publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: marshal public key: %w", err) + } + publicKey := base64.StdEncoding.EncodeToString(publicDER) + + keyName := b.newKeyName() + if err := b.addKeyPair(ctx, identityID.GetResource(), keyName, publicKey, daysToExpiry); err != nil { + return nil, fmt.Errorf("baton-snowflake: register named key pair: %w", err) + } + + fingerprintBytes := sha256.Sum256(publicDER) + metadata := &snowflake.NamedKeyPair{ + Name: keyName, + UserName: identityID.GetResource(), + Fingerprint: "SHA256:" + base64.StdEncoding.EncodeToString(fingerprintBytes[:]), + Status: "ACTIVE", + CreatedOn: now, + } + if input.ExpiresAt != nil { + metadata.ExpiresAt = input.ExpiresAt.AsTime() + } + secret, err := namedKeyPairResource(identityID, metadata) + if err != nil { + return nil, err + } + return &connectorbuilder.CredentialIssueOutput{ + Secret: secret, + PlaintextData: []*v2.PlaintextData{{ + Name: "private_key.pem", + Description: "Snowflake named key-pair private key", + Schema: "application/x-pem-file", + Bytes: privatePEM, + }}, + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, + }, nil +} + +func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { + profiles := make([]*v2.KeyGenerationProfile, 0, 3) + for _, size := range []uint32{2048, 3072, 4096} { + bits := size + profiles = append(profiles, v2.KeyGenerationProfile_builder{Kty: snowflakeRSAKeyType, RsaModulusBits: &bits}.Build()) + } + return v2.CredentialDetailsCredentialIssue_builder{ + Options: []*v2.CredentialIssueOptionDescriptor{ + v2.CredentialIssueOptionDescriptor_builder{ + Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + KeyProfiles: profiles, + Expiry: v2.IssuanceExpiryCapability_builder{ + Min: durationpb.New(24 * time.Hour), + }.Build(), + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, + SecretResourceTypeId: namedKeyPairResourceType.Id, + }.Build(), + }, + PreferredOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + }.Build(), nil, nil +} + +func (b *namedKeyPairBuilder) Delete(ctx context.Context, resourceID, parentResourceID *v2.ResourceId) (annotations.Annotations, error) { + if resourceID == nil || resourceID.GetResourceType() != namedKeyPairResourceType.Id { + return nil, fmt.Errorf("baton-snowflake: invalid named key-pair resource") + } + if parentResourceID == nil || parentResourceID.GetResourceType() != userResourceType.Id || parentResourceID.GetResource() == "" { + return nil, fmt.Errorf("baton-snowflake: named key-pair parent user is required") + } + prefix := parentResourceID.GetResource() + ":" + keyName, found := strings.CutPrefix(resourceID.GetResource(), prefix) + if !found || keyName == "" { + return nil, fmt.Errorf("baton-snowflake: named key-pair resource does not belong to parent user") + } + if err := b.removeKeyPair(ctx, parentResourceID.GetResource(), keyName); err != nil { + return nil, fmt.Errorf("baton-snowflake: remove named key pair: %w", err) + } + return nil, nil +} + +func namedKeyPairResource(identityID *v2.ResourceId, keyPair *snowflake.NamedKeyPair) (*v2.Resource, error) { + resourceID := fmt.Sprintf("%s:%s", keyPair.UserName, keyPair.Name) + secretOptions := []rs.SecretTraitOption{ + rs.WithSecretIdentityID(identityID), + rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY), + rs.WithSecretDetail("snowflake.named_key_pair"), + } + resourceOptions := []rs.ResourceOption{ + rs.WithParentResourceID(identityID), + rs.WithDescription("Snowflake key pair " + keyPair.Fingerprint), + } + if !keyPair.CreatedOn.IsZero() { + resourceOptions = append(resourceOptions, rs.WithResourceCreatedAt(keyPair.CreatedOn)) + } + if !keyPair.LastUsedOn.IsZero() { + secretOptions = append(secretOptions, rs.WithSecretLastUsedAt(keyPair.LastUsedOn)) + } + if !keyPair.ExpiresAt.IsZero() { + secretOptions = append(secretOptions, rs.WithSecretExpiresAt(keyPair.ExpiresAt)) + } + return rs.NewSecretResource( + keyPair.Name, + namedKeyPairResourceType, + resourceID, + secretOptions, + resourceOptions..., + ) +} diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go new file mode 100644 index 00000000..7f27ceaf --- /dev/null +++ b/pkg/connector/named_key_pairs_test.go @@ -0,0 +1,162 @@ +package connector + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-snowflake/pkg/snowflake" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestCredentialIssuingUserBuilderIssueNamedKeyPair(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, time.July, 21, 12, 0, 0, 0, time.UTC) + identityID, err := rs.NewResourceID(userResourceType, "svc_automation") + require.NoError(t, err) + + var registeredUser, registeredName, registeredPublicKey string + var registeredDays int + builder := &credentialIssuingUserBuilder{ + userBuilder: &userBuilder{}, + getUser: func(context.Context, string) (*snowflake.User, error) { + return &snowflake.User{Username: "svc_automation", Type: "SERVICE"}, nil + }, + addKeyPair: func(_ context.Context, user, name, publicKey string, days int) error { + registeredUser = user + registeredName = name + registeredPublicKey = publicKey + registeredDays = days + return nil + }, + newKeyName: func() string { return "c1_test_key" }, + now: func() time.Time { return now }, + } + bits := uint32(2048) + keypair := v2.CredentialIssueOptions_Keypair_builder{ + Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), + }.Build() + options := v2.CredentialIssueOptions_builder{Keypair: keypair}.Build() + + output, err := builder.Issue(ctx, &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: options, + ExpiresAt: timestamppb.New(now.Add(90 * 24 * time.Hour)), + }) + require.NoError(t, err) + secret, plaintext := output.Secret, output.PlaintextData + require.Equal(t, "svc_automation", registeredUser) + require.Equal(t, "c1_test_key", registeredName) + require.Equal(t, 90, registeredDays) + require.Equal(t, "svc_automation:c1_test_key", secret.GetId().GetResource()) + require.Len(t, plaintext, 1) + require.Equal(t, "private_key.pem", plaintext[0].GetName()) + + publicDER, err := base64.StdEncoding.DecodeString(registeredPublicKey) + require.NoError(t, err) + publicValue, err := x509.ParsePKIXPublicKey(publicDER) + require.NoError(t, err) + publicKey, ok := publicValue.(*rsa.PublicKey) + require.True(t, ok) + require.Equal(t, 2048, publicKey.N.BitLen()) + + privateBlock, _ := pem.Decode(plaintext[0].GetBytes()) + require.NotNil(t, privateBlock) + privateValue, err := x509.ParsePKCS8PrivateKey(privateBlock.Bytes) + require.NoError(t, err) + privateKey, ok := privateValue.(*rsa.PrivateKey) + require.True(t, ok) + require.Equal(t, publicKey.N, privateKey.N) +} + +func TestCredentialIssuingUserBuilderRejectsHumanUser(t *testing.T) { + identityID, err := rs.NewResourceID(userResourceType, "alice") + require.NoError(t, err) + builder := &credentialIssuingUserBuilder{ + userBuilder: &userBuilder{}, + getUser: func(context.Context, string) (*snowflake.User, error) { + return &snowflake.User{Username: "alice", Type: "PERSON"}, nil + }, + } + options := v2.CredentialIssueOptions_builder{Keypair: v2.CredentialIssueOptions_Keypair_builder{}.Build()}.Build() + + _, err = builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: identityID, CredentialOptions: options}) + require.ErrorContains(t, err, "only be issued for service users") +} + +func TestCredentialIssuingUserBuilderRejectsPastExpiry(t *testing.T) { + identityID, err := rs.NewResourceID(userResourceType, "svc_automation") + require.NoError(t, err) + builder := &credentialIssuingUserBuilder{ + userBuilder: &userBuilder{}, + getUser: func(context.Context, string) (*snowflake.User, error) { + return &snowflake.User{Username: "svc_automation", Type: "SERVICE"}, nil + }, + now: time.Now, + } + bits := uint32(2048) + keypair := v2.CredentialIssueOptions_Keypair_builder{ + Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), + }.Build() + options := v2.CredentialIssueOptions_builder{Keypair: keypair}.Build() + + _, err = builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: options, + ExpiresAt: timestamppb.New(time.Now().Add(-time.Hour)), + }) + require.ErrorContains(t, err, "must be in the future") +} + +func TestNamedKeyPairBuilderDelete(t *testing.T) { + var gotUser, gotKey string + builder := &namedKeyPairBuilder{removeKeyPair: func(_ context.Context, user, key string) error { + gotUser, gotKey = user, key + return nil + }} + resourceID, err := rs.NewResourceID(namedKeyPairResourceType, "svc:ops:c1_key") + require.NoError(t, err) + parentID, err := rs.NewResourceID(userResourceType, "svc:ops") + require.NoError(t, err) + + _, err = builder.Delete(context.Background(), resourceID, parentID) + require.NoError(t, err) + require.Equal(t, "svc:ops", gotUser) + require.Equal(t, "c1_key", gotKey) +} + +func TestNamedKeyPairBuilderDeleteRejectsMismatchedParent(t *testing.T) { + builder := &namedKeyPairBuilder{removeKeyPair: func(context.Context, string, string) error { + t.Fatal("remove must not be called") + return nil + }} + resourceID, err := rs.NewResourceID(namedKeyPairResourceType, "alice:c1_key") + require.NoError(t, err) + parentID, err := rs.NewResourceID(userResourceType, "bob") + require.NoError(t, err) + + _, err = builder.Delete(context.Background(), resourceID, parentID) + require.ErrorContains(t, err, "does not belong") +} + +func TestNamedKeyPairBuilderListSkipsUnavailableFeature(t *testing.T) { + builder := &namedKeyPairBuilder{listKeyPairs: func(context.Context, string) ([]snowflake.NamedKeyPair, error) { + return nil, status.Error(codes.Unknown, "422 Unprocessable Entity") + }} + parentID, err := rs.NewResourceID(userResourceType, "svc_user") + require.NoError(t, err) + + resources, _, err := builder.List(context.Background(), parentID, rs.SyncOpAttrs{}) + require.NoError(t, err) + require.Empty(t, resources) +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 5436b11d..0e2a2142 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -39,6 +39,12 @@ var ( Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, Annotations: getSkipEntitlementsAnnotation(), } + namedKeyPairResourceType = &v2.ResourceType{ + Id: "named_key_pair", + DisplayName: "Named Key Pair", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, + Annotations: getOptInSkipEntitlementsAnnotation(), + } integrationResourceType = &v2.ResourceType{ Id: "integration", DisplayName: "Integration", @@ -60,6 +66,12 @@ func getSkipEntitlementsAnnotation() annotations.Annotations { return annotations } +func getOptInSkipEntitlementsAnnotation() annotations.Annotations { + annos := getSkipEntitlementsAnnotation() + annos.Update(&v2.OptInRequired{}) + return annos +} + func getLicenseAnnotations() annotations.Annotations { annos := annotations.Annotations{} annos.Update(&v2.SkipEntitlementsAndGrants{}) diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 0fe12c4e..9f73b412 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -67,7 +67,10 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v rs.WithResourceStatus(getUserStatus(user), getUserDetailedStatus(user)), } if syncSecrets { - opts = append(opts, rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id})) + opts = append(opts, + rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id}), + rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: namedKeyPairResourceType.Id}), + ) } resource, err := rs.NewUserResource( @@ -88,7 +91,7 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v func getUserAccountType(user *snowflake.User) v2.UserTrait_AccountType { // https://docs.snowflake.com/en/sql-reference/sql/create-user#label-user-type-property // TYPE = PERSON | SERVICE | LEGACY_SERVICE | NULL - if user.Type == "LEGACY_SERVICE" || user.Type == "SERVICE" { + if user.Type == snowflakeLegacyServiceUserType || user.Type == snowflakeServiceUserType { return v2.UserTrait_ACCOUNT_TYPE_SERVICE } return v2.UserTrait_ACCOUNT_TYPE_HUMAN @@ -421,10 +424,14 @@ func (o *userBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId, par return nil, nil } -func newUserBuilder(client *snowflake.Client, syncSecrets bool) *userBuilder { - return &userBuilder{ +func newUserBuilder(client *snowflake.Client, syncSecrets bool) connectorbuilder.ResourceSyncerV2 { + base := &userBuilder{ resourceType: userResourceType, client: client, syncSecrets: syncSecrets, } + if !syncSecrets { + return base + } + return newCredentialIssuingUserBuilder(base) } diff --git a/pkg/snowflake/client.go b/pkg/snowflake/client.go index 140b07e0..bc1e20cc 100644 --- a/pkg/snowflake/client.go +++ b/pkg/snowflake/client.go @@ -99,17 +99,45 @@ func (m *ResultSetMetadata) GetTimeValueFromRow(row []string, key string) (time. return time.Time{}, fmt.Errorf("row type %s not found", key) } - if rowType.Type != rowTypeTimestampLtz { + // SHOW-family statements are not fully stable across Snowflake releases and + // may report timestamp values as text. Parse either representation. + if rowType.Type != rowTypeTimestampLtz && rowType.Type != rowTypeString { return time.Time{}, fmt.Errorf("column %s is not a timestamp ltz (row type is '%s')", key, rowType.Type) } - if row[i] == "" { + if row[i] == "" || row[i] == rowNull { return time.Time{}, nil } return parseTime(row[i]) } +// ExecuteStatement executes a single non-secret SQL statement through the +// Snowflake statements API. Callers must construct the statement from escaped +// identifiers and non-secret values only. +func (c *Client) ExecuteStatement(ctx context.Context, statement string) error { + req, err := c.PostStatementRequest(ctx, []string{statement}) + if err != nil { + return err + } + var response StatementsApiResponseBase + resp, err := c.Do(req, uhttp.WithJSONResponse(&response)) + defer closeResponseBody(resp) + if err != nil { + return err + } + if response.StatementHandle == "" { + return nil + } + req, err = c.GetStatementResponse(ctx, response.StatementHandle) + if err != nil { + return err + } + resp, err = c.Do(req, uhttp.WithJSONResponse(&response)) + defer closeResponseBody(resp) + return err +} + func (m *ResultSetMetadata) GetStringValueFromRow(row []string, key string) (string, error) { found, i, rowType := m.FindRowTypeByName(key) if !found { @@ -151,6 +179,12 @@ func (m *ResultSetMetadata) ParseRow(s Parsable, row []string) error { for i := 0; i < reflected.NumField(); i++ { field := reflected.Type().Field(i) columnName := s.GetColumnName(field.Name) + if found, _, _ := m.FindRowTypeByName(columnName); !found { + if optional, ok := s.(interface{ IsOptionalField(string) bool }); ok && optional.IsOptionalField(field.Name) { + continue + } + return fmt.Errorf("row type %s not found", columnName) + } switch field.Type.Kind() { case reflect.String: diff --git a/pkg/snowflake/named_key_pairs_test.go b/pkg/snowflake/named_key_pairs_test.go new file mode 100644 index 00000000..820dc544 --- /dev/null +++ b/pkg/snowflake/named_key_pairs_test.go @@ -0,0 +1,31 @@ +package snowflake + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListNamedKeyPairsRawResponseGetKeyPairsToleratesOptionalColumns(t *testing.T) { + response := &ListNamedKeyPairsRawResponse{ + StatementsApiResponseBase: StatementsApiResponseBase{ + ResultSetMetadata: ResultSetMetadata{RowTypes: []RowType{ + {Name: "name", Type: rowTypeString}, + {Name: "user_name", Type: rowTypeString}, + {Name: "fingerprint", Type: rowTypeString}, + {Name: "status", Type: rowTypeString}, + {Name: "created_on", Type: rowTypeString}, + }}, + Data: [][]string{{"c1_key", "svc_user", "SHA256:test", "ACTIVE", "1784682000.000000000"}}, + }, + } + + keyPairs, err := response.GetKeyPairs() + require.NoError(t, err) + require.Len(t, keyPairs, 1) + require.Equal(t, "c1_key", keyPairs[0].Name) + require.Equal(t, "svc_user", keyPairs[0].UserName) + require.False(t, keyPairs[0].CreatedOn.IsZero()) + require.True(t, keyPairs[0].LastUsedOn.IsZero()) + require.True(t, keyPairs[0].ExpiresAt.IsZero()) +} diff --git a/pkg/snowflake/user.go b/pkg/snowflake/user.go index 24c8d6c2..73bda1b8 100644 --- a/pkg/snowflake/user.go +++ b/pkg/snowflake/user.go @@ -58,6 +58,20 @@ var ( "Default": "default", "Description": "description", } + + namedKeyPairStructFieldToColumnMap = map[string]string{ + "Name": columnName, + "UserName": "user_name", + "Fingerprint": "fingerprint", + "RoleScope": "role_scope", + "Status": "status", + "Comment": "comment", + "CreatedOn": columnCreatedOn, + "CreatedBy": "created_by", + "LastUsedOn": "last_used_on", + "ExpiresAt": "expires_at", + "RotatedTo": "rotated_to", + } ) type ( @@ -108,6 +122,24 @@ type ( StatementsApiResponseBase } + ListNamedKeyPairsRawResponse struct { + StatementsApiResponseBase + } + + NamedKeyPair struct { + Name string + UserName string + Fingerprint string + RoleScope string + Status string + Comment string + CreatedOn time.Time + CreatedBy string + LastUsedOn time.Time + ExpiresAt time.Time + RotatedTo string + } + Secret struct { CreatedOn time.Time Name string @@ -121,6 +153,79 @@ type ( } ) +func (k *NamedKeyPair) GetColumnName(fieldName string) string { + return namedKeyPairStructFieldToColumnMap[fieldName] +} + +func (k *NamedKeyPair) IsOptionalField(fieldName string) bool { + switch fieldName { + case "RoleScope", "Comment", "CreatedBy", "LastUsedOn", "ExpiresAt", "RotatedTo": + return true + default: + return false + } +} + +func (r *ListNamedKeyPairsRawResponse) GetKeyPairs() ([]NamedKeyPair, error) { + keyPairs := make([]NamedKeyPair, 0, len(r.Data)) + for _, row := range r.Data { + keyPair := &NamedKeyPair{} + if err := r.ResultSetMetadata.ParseRow(keyPair, row); err != nil { + return nil, err + } + keyPairs = append(keyPairs, *keyPair) + } + return keyPairs, nil +} + +func (c *Client) AddUserKeyPair(ctx context.Context, username, keyPairName, publicKey string, daysToExpiry int) error { + statement := fmt.Sprintf( + "ALTER USER IF EXISTS \"%s\" ADD KEY PAIR \"%s\" PUBLIC_KEY = '%s'", + escapeDoubleQuotedIdentifier(username), + escapeDoubleQuotedIdentifier(keyPairName), + publicKey, + ) + if daysToExpiry > 0 { + statement += fmt.Sprintf(" DAYS_TO_EXPIRY = %d", daysToExpiry) + } + return c.ExecuteStatement(ctx, statement+";") +} + +func (c *Client) RemoveUserKeyPair(ctx context.Context, username, keyPairName string) error { + statement := fmt.Sprintf( + "ALTER USER IF EXISTS \"%s\" REMOVE KEY PAIR \"%s\";", + escapeDoubleQuotedIdentifier(username), + escapeDoubleQuotedIdentifier(keyPairName), + ) + return c.ExecuteStatement(ctx, statement) +} + +func (c *Client) ListUserKeyPairs(ctx context.Context, username string) ([]NamedKeyPair, error) { + statement := fmt.Sprintf("SHOW USER KEY PAIRS FOR USER \"%s\";", escapeDoubleQuotedIdentifier(username)) + req, err := c.PostStatementRequest(ctx, []string{statement}) + if err != nil { + return nil, err + } + var response ListNamedKeyPairsRawResponse + resp1, err := c.Do(req, uhttp.WithJSONResponse(&response)) + defer closeResponseBody(resp1) + if err != nil { + return nil, err + } + if response.StatementHandle != "" { + req, err = c.GetStatementResponse(ctx, response.StatementHandle) + if err != nil { + return nil, err + } + resp2, err := c.Do(req, uhttp.WithJSONResponse(&response)) + defer closeResponseBody(resp2) + if err != nil { + return nil, err + } + } + return response.GetKeyPairs() +} + func (u *Secret) GetColumnName(fieldName string) string { return secretStructFieldToColumnMap[fieldName] }