From a25381fe1caed56ac90238db9b7810ff6958af1e Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 21 Jul 2026 22:32:02 +0000 Subject: [PATCH 1/9] feat: prototype Snowflake named key pair issuance Co-authored-by: c1-squire-dev[bot] --- pkg/connector/connector.go | 1 + pkg/connector/named_key_pairs.go | 202 ++++++++++++++++++ pkg/connector/named_key_pairs_test.go | 107 ++++++++++ pkg/connector/resource_types.go | 6 + pkg/connector/users.go | 13 +- pkg/snowflake/client.go | 28 ++- pkg/snowflake/user.go | 87 ++++++++ .../pkg/connectorbuilder/connectorbuilder.go | 21 ++ .../baton-sdk/pkg/crypto/crypto.go | 21 ++ 9 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 pkg/connector/named_key_pairs.go create mode 100644 pkg/connector/named_key_pairs_test.go 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/named_key_pairs.go b/pkg/connector/named_key_pairs.go new file mode 100644 index 00000000..86933965 --- /dev/null +++ b/pkg/connector/named_key_pairs.go @@ -0,0 +1,202 @@ +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/segmentio/ksuid" +) + +type namedKeyPairBuilder struct { + client *snowflake.Client +} + +func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { + return &namedKeyPairBuilder{client: client} +} + +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.client.ListUserKeyPairs(ctx, parent.GetResource()) + if err != 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) + +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, + identityID *v2.ResourceId, + credentialOptions *v2.LocalCredentialOptions, +) (*v2.Resource, []*v2.PlaintextData, annotations.Annotations, error) { + if identityID == nil || identityID.GetResourceType() != userResourceType.Id { + return nil, nil, nil, fmt.Errorf("baton-snowflake: invalid service user identity") + } + user, err := b.getUser(ctx, identityID.GetResource()) + if err != nil { + return nil, nil, nil, fmt.Errorf("baton-snowflake: get credential target: %w", err) + } + if user.Type != "SERVICE" && user.Type != "LEGACY_SERVICE" { + return nil, nil, nil, fmt.Errorf("baton-snowflake: key pairs may only be issued for service users") + } + + if credentialOptions == nil { + return nil, nil, nil, fmt.Errorf("baton-snowflake: credential options are required") + } + keypair := credentialOptions.GetKeypair() + if keypair == nil { + return nil, nil, nil, fmt.Errorf("baton-snowflake: only keypair credentials are supported") + } + if algorithm := strings.ToUpper(keypair.GetAlgorithm()); algorithm != "" && algorithm != "RSA" { + return nil, nil, nil, fmt.Errorf("baton-snowflake: unsupported key algorithm %q", keypair.GetAlgorithm()) + } + bits := int(keypair.GetBits()) + if bits == 0 { + bits = 2048 + } + if bits != 2048 && bits != 3072 && bits != 4096 { + return nil, nil, nil, fmt.Errorf("baton-snowflake: unsupported RSA key size %d", bits) + } + + daysToExpiry := 0 + if ttl := keypair.GetTtl(); ttl != nil { + if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.AsDuration()%(24*time.Hour) != 0 { + return nil, nil, nil, fmt.Errorf("baton-snowflake: keypair TTL must be a positive whole number of days") + } + daysToExpiry = int(ttl.AsDuration() / (24 * time.Hour)) + } + + privateKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, nil, fmt.Errorf("baton-snowflake: generate RSA key: %w", err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, nil, 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, nil, 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, nil, nil, fmt.Errorf("baton-snowflake: register named key pair: %w", err) + } + + now := b.now().UTC() + fingerprintBytes := sha256.Sum256(publicDER) + metadata := &snowflake.NamedKeyPair{ + Name: keyName, + UserName: identityID.GetResource(), + Fingerprint: "SHA256:" + base64.StdEncoding.EncodeToString(fingerprintBytes[:]), + Status: "ACTIVE", + CreatedOn: now, + } + if daysToExpiry > 0 { + metadata.ExpiresAt = now.Add(time.Duration(daysToExpiry) * 24 * time.Hour) + } + secret, err := namedKeyPairResource(identityID, metadata) + if err != nil { + return nil, nil, nil, err + } + return secret, []*v2.PlaintextData{{ + Name: "private_key.pem", + Description: "Snowflake named key-pair private key", + Schema: "application/x-pem-file", + Bytes: privatePEM, + }}, nil, nil +} + +func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { + return &v2.CredentialDetailsCredentialIssue{ + SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{ + v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + }, + PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + }, 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.WithSecretCreatedAt(keyPair.CreatedOn), + rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY), + rs.WithSecretDetail("snowflake.named_key_pair"), + } + 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, + rs.WithParentResourceID(identityID), + rs.WithDescription("Snowflake key pair "+keyPair.Fingerprint), + ) +} diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go new file mode 100644 index 00000000..8752f595 --- /dev/null +++ b/pkg/connector/named_key_pairs_test.go @@ -0,0 +1,107 @@ +package connector + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-snowflake/pkg/snowflake" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" +) + +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 }, + } + keypair := &v2.LocalCredentialOptions_Keypair{} + keypair.SetAlgorithm("RSA") + keypair.SetBits(2048) + keypair.SetTtl(durationpb.New(90 * 24 * time.Hour)) + options := &v2.LocalCredentialOptions{} + options.SetKeypair(keypair) + + secret, plaintext, _, err := builder.Issue(ctx, identityID, options) + require.NoError(t, err) + 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.PublicKey.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.LocalCredentialOptions{} + options.SetKeypair(&v2.LocalCredentialOptions_Keypair{}) + + _, _, _, err = builder.Issue(context.Background(), identityID, options) + require.ErrorContains(t, err, "only be issued for service users") +} + +func TestCredentialIssuingUserBuilderRejectsFractionalDayTTL(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 + }, + } + keypair := &v2.LocalCredentialOptions_Keypair{} + keypair.SetTtl(durationpb.New(36 * time.Hour)) + options := &v2.LocalCredentialOptions{} + options.SetKeypair(keypair) + + _, _, _, err = builder.Issue(context.Background(), identityID, options) + require.ErrorContains(t, err, "positive whole number of days") +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 5436b11d..11fea7f1 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: getSkipEntitlementsAnnotation(), + } integrationResourceType = &v2.ResourceType{ Id: "integration", DisplayName: "Integration", diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 0fe12c4e..0e161bc8 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( @@ -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..4e2913e3 100644 --- a/pkg/snowflake/client.go +++ b/pkg/snowflake/client.go @@ -103,13 +103,39 @@ func (m *ResultSetMetadata) GetTimeValueFromRow(row []string, key string) (time. 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 { diff --git a/pkg/snowflake/user.go b/pkg/snowflake/user.go index 24c8d6c2..636b39c8 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": "name", + "UserName": "user_name", + "Fingerprint": "fingerprint", + "RoleScope": "role_scope", + "Status": "status", + "Comment": "comment", + "CreatedOn": "created_on", + "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,61 @@ type ( } ) +func (k *NamedKeyPair) GetColumnName(fieldName string) string { + return namedKeyPairStructFieldToColumnMap[fieldName] +} + +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) 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] } 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..b5f3f85d 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,27 @@ 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(), + Ttl: opts.GetKeypair().GetTtl(), + }.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 43d705378a56cbc8f7ceaa1fbf10b4e13f6d0e5e Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Tue, 21 Jul 2026 22:34:09 +0000 Subject: [PATCH 2/9] fix: store Snowflake key creation as resource metadata Co-authored-by: c1-squire-dev[bot] --- pkg/connector/named_key_pairs.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go index 86933965..591e3477 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -181,10 +181,16 @@ func namedKeyPairResource(identityID *v2.ResourceId, keyPair *snowflake.NamedKey resourceID := fmt.Sprintf("%s:%s", keyPair.UserName, keyPair.Name) secretOptions := []rs.SecretTraitOption{ rs.WithSecretIdentityID(identityID), - rs.WithSecretCreatedAt(keyPair.CreatedOn), 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)) } @@ -196,7 +202,6 @@ func namedKeyPairResource(identityID *v2.ResourceId, keyPair *snowflake.NamedKey namedKeyPairResourceType, resourceID, secretOptions, - rs.WithParentResourceID(identityID), - rs.WithDescription("Snowflake key pair "+keyPair.Fingerprint), + resourceOptions..., ) } From eb93e365f9fac9c099ad4c695541a4be0ff94b9c Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:18:38 +0000 Subject: [PATCH 3/9] feat(credentials): adopt finalized issuance contract Use explicit JOSE RSA profiles, issuance-level lifetimes, dynamic service-user eligibility, and resilient named-key row parsing with regression coverage. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/named_key_pairs.go | 104 +++++++++++------- pkg/connector/named_key_pairs_test.go | 30 +++-- pkg/snowflake/client.go | 10 +- pkg/snowflake/named_key_pairs_test.go | 31 ++++++ pkg/snowflake/user.go | 9 ++ .../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 | 24 ++-- .../baton-sdk/pkg/sdk/empty_connector.go | 4 + 10 files changed, 214 insertions(+), 60 deletions(-) create mode 100644 pkg/snowflake/named_key_pairs_test.go diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go index 591e3477..22b0ed51 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -9,7 +9,6 @@ import ( "encoding/base64" "encoding/pem" "fmt" - "strings" "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -18,6 +17,7 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-snowflake/pkg/snowflake" "github.com/segmentio/ksuid" + "google.golang.org/protobuf/types/known/durationpb" ) type namedKeyPairBuilder struct { @@ -84,64 +84,61 @@ func newCredentialIssuingUserBuilder(base *userBuilder) *credentialIssuingUserBu func (b *credentialIssuingUserBuilder) 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() != userResourceType.Id { - return nil, nil, nil, fmt.Errorf("baton-snowflake: invalid service user identity") + return nil, fmt.Errorf("baton-snowflake: invalid service user identity") } user, err := b.getUser(ctx, identityID.GetResource()) if err != nil { - return nil, nil, nil, fmt.Errorf("baton-snowflake: get credential target: %w", err) + return nil, fmt.Errorf("baton-snowflake: get credential target: %w", err) } if user.Type != "SERVICE" && user.Type != "LEGACY_SERVICE" { - return nil, nil, nil, fmt.Errorf("baton-snowflake: key pairs may only be issued for service users") + return nil, fmt.Errorf("baton-snowflake: key pairs may only be issued for service users") } - if credentialOptions == nil { - return nil, nil, nil, fmt.Errorf("baton-snowflake: credential options are required") + if input.CredentialOptions == nil { + return nil, fmt.Errorf("baton-snowflake: credential options are required") } - keypair := credentialOptions.GetKeypair() + keypair := input.CredentialOptions.GetKeypair() if keypair == nil { - return nil, nil, nil, fmt.Errorf("baton-snowflake: only keypair credentials are supported") + return nil, fmt.Errorf("baton-snowflake: only keypair credentials are supported") } - if algorithm := strings.ToUpper(keypair.GetAlgorithm()); algorithm != "" && algorithm != "RSA" { - return nil, nil, nil, fmt.Errorf("baton-snowflake: unsupported key algorithm %q", keypair.GetAlgorithm()) - } - bits := int(keypair.GetBits()) - if bits == 0 { - bits = 2048 + if keypair.GetProfile().GetKty() != "RSA" { + 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, nil, nil, fmt.Errorf("baton-snowflake: unsupported RSA key size %d", bits) + return nil, fmt.Errorf("baton-snowflake: unsupported RSA key size %d", bits) } daysToExpiry := 0 - if ttl := keypair.GetTtl(); ttl != nil { + if ttl := input.IssuanceConstraints.GetLifetime(); ttl != nil { if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.AsDuration()%(24*time.Hour) != 0 { - return nil, nil, nil, fmt.Errorf("baton-snowflake: keypair TTL must be a positive whole number of days") + return nil, fmt.Errorf("baton-snowflake: keypair lifetime must be a positive whole number of days") } daysToExpiry = int(ttl.AsDuration() / (24 * time.Hour)) } privateKey, err := rsa.GenerateKey(rand.Reader, bits) if err != nil { - return nil, nil, nil, fmt.Errorf("baton-snowflake: generate RSA key: %w", err) + return nil, fmt.Errorf("baton-snowflake: generate RSA key: %w", err) } privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) if err != nil { - return nil, nil, nil, fmt.Errorf("baton-snowflake: marshal private key: %w", err) + 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, nil, nil, fmt.Errorf("baton-snowflake: marshal public key: %w", err) + 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, nil, nil, fmt.Errorf("baton-snowflake: register named key pair: %w", err) + return nil, fmt.Errorf("baton-snowflake: register named key pair: %w", err) } now := b.now().UTC() @@ -158,23 +155,56 @@ func (b *credentialIssuingUserBuilder) Issue( } secret, err := namedKeyPairResource(identityID, metadata) if err != nil { - return nil, nil, nil, err - } - return secret, []*v2.PlaintextData{{ - Name: "private_key.pem", - Description: "Snowflake named key-pair private key", - Schema: "application/x-pem-file", - Bytes: privatePEM, - }}, nil, 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, + }}, + }, nil } func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { - return &v2.CredentialDetailsCredentialIssue{ - SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{ - v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + profiles := make([]*v2.KeyGenerationProfile, 0, 3) + for _, size := range []uint32{2048, 3072, 4096} { + bits := size + profiles = append(profiles, v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build()) + } + return v2.CredentialDetailsCredentialIssue_builder{ + Options: []*v2.CredentialIssueOptionDescriptor{ + v2.CredentialIssueOptionDescriptor_builder{ + Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + KeyProfiles: profiles, + Lifetime: v2.IssuanceLifetimeCapability_builder{ + Min: durationpb.New(24 * time.Hour), + Granularity: durationpb.New(24 * time.Hour), + }.Build(), + }.Build(), }, - PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, - }, nil, nil + PreferredOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, + }.Build(), nil, nil +} + +func (b *credentialIssuingUserBuilder) GetCredentialIssueEligibility(ctx context.Context, identityID *v2.ResourceId, _ v2.CapabilityDetailCredentialOption) (*v2.GetCredentialIssueEligibilityResponse, error) { + if identityID == nil || identityID.GetResourceType() != userResourceType.Id { + return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_INELIGIBLE, ReasonCode: "invalid_identity"}.Build(), nil + } + user, err := b.getUser(ctx, identityID.GetResource()) + if err != nil { + return nil, err + } + if user.Type == "SERVICE" || user.Type == "LEGACY_SERVICE" { + return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_ELIGIBLE}.Build(), nil + } + return v2.GetCredentialIssueEligibilityResponse_builder{ + Status: v2.GetCredentialIssueEligibilityResponse_STATUS_INELIGIBLE, + ReasonCode: "not_service_user", + Explanation: "Snowflake named key pairs may only be issued for service users", + }.Build(), nil } func namedKeyPairResource(identityID *v2.ResourceId, keyPair *snowflake.NamedKeyPair) (*v2.Resource, error) { diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go index 8752f595..cc99a0fe 100644 --- a/pkg/connector/named_key_pairs_test.go +++ b/pkg/connector/named_key_pairs_test.go @@ -10,6 +10,7 @@ import ( "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" @@ -39,15 +40,20 @@ func TestCredentialIssuingUserBuilderIssueNamedKeyPair(t *testing.T) { newKeyName: func() string { return "c1_test_key" }, now: func() time.Time { return now }, } - keypair := &v2.LocalCredentialOptions_Keypair{} - keypair.SetAlgorithm("RSA") - keypair.SetBits(2048) - keypair.SetTtl(durationpb.New(90 * 24 * time.Hour)) + bits := uint32(2048) + keypair := v2.LocalCredentialOptions_Keypair_builder{ + Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), + }.Build() options := &v2.LocalCredentialOptions{} options.SetKeypair(keypair) - secret, plaintext, _, err := builder.Issue(ctx, identityID, options) + output, err := builder.Issue(ctx, &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: options, + IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(90 * 24 * time.Hour)}.Build(), + }) 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) @@ -84,7 +90,7 @@ func TestCredentialIssuingUserBuilderRejectsHumanUser(t *testing.T) { options := &v2.LocalCredentialOptions{} options.SetKeypair(&v2.LocalCredentialOptions_Keypair{}) - _, _, _, err = builder.Issue(context.Background(), identityID, options) + _, err = builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{IdentityID: identityID, CredentialOptions: options}) require.ErrorContains(t, err, "only be issued for service users") } @@ -97,11 +103,17 @@ func TestCredentialIssuingUserBuilderRejectsFractionalDayTTL(t *testing.T) { return &snowflake.User{Username: "svc_automation", Type: "SERVICE"}, nil }, } - keypair := &v2.LocalCredentialOptions_Keypair{} - keypair.SetTtl(durationpb.New(36 * time.Hour)) + bits := uint32(2048) + keypair := v2.LocalCredentialOptions_Keypair_builder{ + Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), + }.Build() options := &v2.LocalCredentialOptions{} options.SetKeypair(keypair) - _, _, _, err = builder.Issue(context.Background(), identityID, options) + _, err = builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + IdentityID: identityID, + CredentialOptions: options, + IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(36 * time.Hour)}.Build(), + }) require.ErrorContains(t, err, "positive whole number of days") } diff --git a/pkg/snowflake/client.go b/pkg/snowflake/client.go index 4e2913e3..bc1e20cc 100644 --- a/pkg/snowflake/client.go +++ b/pkg/snowflake/client.go @@ -99,7 +99,9 @@ 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) } @@ -177,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 636b39c8..0dcf0924 100644 --- a/pkg/snowflake/user.go +++ b/pkg/snowflake/user.go @@ -157,6 +157,15 @@ 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 { 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 b5f3f85d..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,24 +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(), - Ttl: opts.GetKeypair().GetTtl(), + 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 a8190e8cefc000a6da009468493a39b19a7c50be Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:29:52 +0000 Subject: [PATCH 4/9] fix(credentials): satisfy connector lint Centralize Snowflake service-user type constants and simplify the RSA public-key assertion. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/named_key_pairs.go | 9 +++++++-- pkg/connector/named_key_pairs_test.go | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go index 22b0ed51..b02cc838 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -24,6 +24,11 @@ type namedKeyPairBuilder struct { client *snowflake.Client } +const ( + snowflakeServiceUserType = "SERVICE" + snowflakeLegacyServiceUserType = "LEGACY_SERVICE" +) + func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { return &namedKeyPairBuilder{client: client} } @@ -94,7 +99,7 @@ func (b *credentialIssuingUserBuilder) Issue( if err != nil { return nil, fmt.Errorf("baton-snowflake: get credential target: %w", err) } - if user.Type != "SERVICE" && user.Type != "LEGACY_SERVICE" { + if user.Type != snowflakeServiceUserType && user.Type != snowflakeLegacyServiceUserType { return nil, fmt.Errorf("baton-snowflake: key pairs may only be issued for service users") } @@ -197,7 +202,7 @@ func (b *credentialIssuingUserBuilder) GetCredentialIssueEligibility(ctx context if err != nil { return nil, err } - if user.Type == "SERVICE" || user.Type == "LEGACY_SERVICE" { + if user.Type == snowflakeServiceUserType || user.Type == snowflakeLegacyServiceUserType { return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_ELIGIBLE}.Build(), nil } return v2.GetCredentialIssueEligibilityResponse_builder{ diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go index cc99a0fe..e5cfd521 100644 --- a/pkg/connector/named_key_pairs_test.go +++ b/pkg/connector/named_key_pairs_test.go @@ -75,7 +75,7 @@ func TestCredentialIssuingUserBuilderIssueNamedKeyPair(t *testing.T) { require.NoError(t, err) privateKey, ok := privateValue.(*rsa.PrivateKey) require.True(t, ok) - require.Equal(t, publicKey.N, privateKey.PublicKey.N) + require.Equal(t, publicKey.N, privateKey.N) } func TestCredentialIssuingUserBuilderRejectsHumanUser(t *testing.T) { From e319b8de271e2d5eadaba6053d01169d02980c1a Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:35:45 +0000 Subject: [PATCH 5/9] fix(lint): centralize Snowflake constants Reuse service-user, RSA key-type, and result-column constants and wrap the eligibility signature. Co-authored-by: c1-squire-dev[bot] --- pkg/connector/named_key_pairs.go | 11 ++++++++--- pkg/connector/users.go | 2 +- pkg/snowflake/user.go | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go index b02cc838..c4728c17 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -27,6 +27,7 @@ type namedKeyPairBuilder struct { const ( snowflakeServiceUserType = "SERVICE" snowflakeLegacyServiceUserType = "LEGACY_SERVICE" + snowflakeRSAKeyType = "RSA" ) func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { @@ -110,7 +111,7 @@ func (b *credentialIssuingUserBuilder) Issue( if keypair == nil { return nil, fmt.Errorf("baton-snowflake: only keypair credentials are supported") } - if keypair.GetProfile().GetKty() != "RSA" { + if keypair.GetProfile().GetKty() != snowflakeRSAKeyType { return nil, fmt.Errorf("baton-snowflake: only RSA key pairs are supported") } bits := int(keypair.GetProfile().GetRsaModulusBits()) @@ -177,7 +178,7 @@ func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v profiles := make([]*v2.KeyGenerationProfile, 0, 3) for _, size := range []uint32{2048, 3072, 4096} { bits := size - profiles = append(profiles, v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build()) + profiles = append(profiles, v2.KeyGenerationProfile_builder{Kty: snowflakeRSAKeyType, RsaModulusBits: &bits}.Build()) } return v2.CredentialDetailsCredentialIssue_builder{ Options: []*v2.CredentialIssueOptionDescriptor{ @@ -194,7 +195,11 @@ func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v }.Build(), nil, nil } -func (b *credentialIssuingUserBuilder) GetCredentialIssueEligibility(ctx context.Context, identityID *v2.ResourceId, _ v2.CapabilityDetailCredentialOption) (*v2.GetCredentialIssueEligibilityResponse, error) { +func (b *credentialIssuingUserBuilder) GetCredentialIssueEligibility( + ctx context.Context, + identityID *v2.ResourceId, + _ v2.CapabilityDetailCredentialOption, +) (*v2.GetCredentialIssueEligibilityResponse, error) { if identityID == nil || identityID.GetResourceType() != userResourceType.Id { return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_INELIGIBLE, ReasonCode: "invalid_identity"}.Build(), nil } diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 0e161bc8..9f73b412 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -91,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 diff --git a/pkg/snowflake/user.go b/pkg/snowflake/user.go index 0dcf0924..2e45ee06 100644 --- a/pkg/snowflake/user.go +++ b/pkg/snowflake/user.go @@ -60,13 +60,13 @@ var ( } namedKeyPairStructFieldToColumnMap = map[string]string{ - "Name": "name", + "Name": columnName, "UserName": "user_name", "Fingerprint": "fingerprint", "RoleScope": "role_scope", "Status": "status", "Comment": "comment", - "CreatedOn": "created_on", + "CreatedOn": columnCreatedOn, "CreatedBy": "created_by", "LastUsedOn": "last_used_on", "ExpiresAt": "expires_at", From 4e94c3adff079f73fe4e310d34d18a35b70cf21d Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 22 Jul 2026 08:42:55 +0000 Subject: [PATCH 6/9] docs(credentials): update Snowflake capability metadata Regenerate credential issuance capabilities and document named key-pair issuance constraints. Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 52 ++++++++++++++++++++++++++++++++++++++--- docs/connector.mdx | 6 +++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index d9f01eed..bda52399 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -68,6 +68,24 @@ "permissions": {}, "optInRequired": true }, + { + "resourceType": { + "id": "named_key_pair", + "displayName": "Named Key Pair", + "traits": [ + "TRAIT_SECRET" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC" + ], + "permissions": {} + }, { "resourceType": { "id": "rsa_public_key", @@ -133,7 +151,8 @@ "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], "permissions": {} } @@ -142,7 +161,8 @@ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], "credentialDetails": { "capabilityAccountProvisioning": { @@ -151,6 +171,32 @@ "CAPABILITY_DETAIL_CREDENTIAL_OPTION_ENCRYPTED_PASSWORD" ], "preferredCredentialOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD" + }, + "capabilityCredentialIssue": { + "options": [ + { + "option": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR", + "keyProfiles": [ + { + "kty": "RSA", + "rsaModulusBits": 2048 + }, + { + "kty": "RSA", + "rsaModulusBits": 3072 + }, + { + "kty": "RSA", + "rsaModulusBits": 4096 + } + ], + "lifetime": { + "min": "86400s", + "granularity": "86400s" + } + } + ], + "preferredOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR" } } -} \ No newline at end of file +} 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. From 1082ff7bf4891abcfdb945e8e1084743d5461cab Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 17:35:10 +0000 Subject: [PATCH 7/9] fix(credentials): adopt released issuance lifecycle Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 60 ++++++++--------- go.mod | 8 ++- pkg/connector/named_key_pairs.go | 65 ++++++++++--------- pkg/connector/named_key_pairs_test.go | 63 +++++++++++++----- pkg/snowflake/user.go | 9 +++ .../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 -- 10 files changed, 129 insertions(+), 186 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index bda52399..1ceda7b0 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -82,7 +82,8 @@ ] }, "capabilities": [ - "CAPABILITY_SYNC" + "CAPABILITY_SYNC", + "CAPABILITY_RESOURCE_DELETE" ], "permissions": {} }, @@ -154,7 +155,34 @@ "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": [ @@ -171,32 +199,6 @@ "CAPABILITY_DETAIL_CREDENTIAL_OPTION_ENCRYPTED_PASSWORD" ], "preferredCredentialOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_RANDOM_PASSWORD" - }, - "capabilityCredentialIssue": { - "options": [ - { - "option": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR", - "keyProfiles": [ - { - "kty": "RSA", - "rsaModulusBits": 2048 - }, - { - "kty": "RSA", - "rsaModulusBits": 3072 - }, - { - "kty": "RSA", - "rsaModulusBits": 4096 - } - ], - "lifetime": { - "min": "86400s", - "granularity": "86400s" - } - } - ], - "preferredOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR" } } -} +} \ No newline at end of file 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/named_key_pairs.go b/pkg/connector/named_key_pairs.go index c4728c17..a9c745a4 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "encoding/pem" "fmt" + "strings" "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -21,7 +22,8 @@ import ( ) type namedKeyPairBuilder struct { - client *snowflake.Client + client *snowflake.Client + removeKeyPair func(context.Context, string, string) error } const ( @@ -31,7 +33,7 @@ const ( ) func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { - return &namedKeyPairBuilder{client: client} + return &namedKeyPairBuilder{client: client, removeKeyPair: client.RemoveUserKeyPair} } func (*namedKeyPairBuilder) ResourceType(context.Context) *v2.ResourceType { @@ -74,6 +76,7 @@ type credentialIssuingUserBuilder struct { } var _ connectorbuilder.CredentialIssuerV2 = (*credentialIssuingUserBuilder)(nil) +var _ connectorbuilder.ResourceDeleterV2 = (*namedKeyPairBuilder)(nil) func newCredentialIssuingUserBuilder(base *userBuilder) *credentialIssuingUserBuilder { return &credentialIssuingUserBuilder{ @@ -119,12 +122,19 @@ func (b *credentialIssuingUserBuilder) Issue( return nil, fmt.Errorf("baton-snowflake: unsupported RSA key size %d", bits) } + now := b.now().UTC() daysToExpiry := 0 - if ttl := input.IssuanceConstraints.GetLifetime(); ttl != nil { - if err := ttl.CheckValid(); err != nil || ttl.AsDuration() <= 0 || ttl.AsDuration()%(24*time.Hour) != 0 { - return nil, fmt.Errorf("baton-snowflake: keypair lifetime must be a positive whole number of days") + 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") } - daysToExpiry = int(ttl.AsDuration() / (24 * time.Hour)) + // 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) @@ -147,7 +157,6 @@ func (b *credentialIssuingUserBuilder) Issue( return nil, fmt.Errorf("baton-snowflake: register named key pair: %w", err) } - now := b.now().UTC() fingerprintBytes := sha256.Sum256(publicDER) metadata := &snowflake.NamedKeyPair{ Name: keyName, @@ -156,8 +165,8 @@ func (b *credentialIssuingUserBuilder) Issue( Status: "ACTIVE", CreatedOn: now, } - if daysToExpiry > 0 { - metadata.ExpiresAt = now.Add(time.Duration(daysToExpiry) * 24 * time.Hour) + if input.ExpiresAt != nil { + metadata.ExpiresAt = input.ExpiresAt.AsTime() } secret, err := namedKeyPairResource(identityID, metadata) if err != nil { @@ -171,6 +180,7 @@ func (b *credentialIssuingUserBuilder) Issue( Schema: "application/x-pem-file", Bytes: privatePEM, }}, + ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE, }, nil } @@ -185,36 +195,33 @@ func (*credentialIssuingUserBuilder) IssueCapabilityDetails(context.Context) (*v v2.CredentialIssueOptionDescriptor_builder{ Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_KEYPAIR, KeyProfiles: profiles, - Lifetime: v2.IssuanceLifetimeCapability_builder{ - Min: durationpb.New(24 * time.Hour), - Granularity: durationpb.New(24 * time.Hour), + 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 *credentialIssuingUserBuilder) GetCredentialIssueEligibility( - ctx context.Context, - identityID *v2.ResourceId, - _ v2.CapabilityDetailCredentialOption, -) (*v2.GetCredentialIssueEligibilityResponse, error) { - if identityID == nil || identityID.GetResourceType() != userResourceType.Id { - return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_INELIGIBLE, ReasonCode: "invalid_identity"}.Build(), 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") } - user, err := b.getUser(ctx, identityID.GetResource()) - if err != nil { - return nil, err + 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 user.Type == snowflakeServiceUserType || user.Type == snowflakeLegacyServiceUserType { - return v2.GetCredentialIssueEligibilityResponse_builder{Status: v2.GetCredentialIssueEligibilityResponse_STATUS_ELIGIBLE}.Build(), nil + if err := b.removeKeyPair(ctx, parentResourceID.GetResource(), keyName); err != nil { + return nil, fmt.Errorf("baton-snowflake: remove named key pair: %w", err) } - return v2.GetCredentialIssueEligibilityResponse_builder{ - Status: v2.GetCredentialIssueEligibilityResponse_STATUS_INELIGIBLE, - ReasonCode: "not_service_user", - Explanation: "Snowflake named key pairs may only be issued for service users", - }.Build(), nil + return nil, nil } func namedKeyPairResource(identityID *v2.ResourceId, keyPair *snowflake.NamedKeyPair) (*v2.Resource, error) { diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go index e5cfd521..3748fbab 100644 --- a/pkg/connector/named_key_pairs_test.go +++ b/pkg/connector/named_key_pairs_test.go @@ -14,7 +14,7 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-snowflake/pkg/snowflake" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestCredentialIssuingUserBuilderIssueNamedKeyPair(t *testing.T) { @@ -41,16 +41,15 @@ func TestCredentialIssuingUserBuilderIssueNamedKeyPair(t *testing.T) { now: func() time.Time { return now }, } bits := uint32(2048) - keypair := v2.LocalCredentialOptions_Keypair_builder{ + keypair := v2.CredentialIssueOptions_Keypair_builder{ Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), }.Build() - options := &v2.LocalCredentialOptions{} - options.SetKeypair(keypair) + options := v2.CredentialIssueOptions_builder{Keypair: keypair}.Build() output, err := builder.Issue(ctx, &connectorbuilder.CredentialIssueInput{ - IdentityID: identityID, - CredentialOptions: options, - IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(90 * 24 * time.Hour)}.Build(), + IdentityID: identityID, + CredentialOptions: options, + ExpiresAt: timestamppb.New(now.Add(90 * 24 * time.Hour)), }) require.NoError(t, err) secret, plaintext := output.Secret, output.PlaintextData @@ -87,14 +86,13 @@ func TestCredentialIssuingUserBuilderRejectsHumanUser(t *testing.T) { return &snowflake.User{Username: "alice", Type: "PERSON"}, nil }, } - options := &v2.LocalCredentialOptions{} - options.SetKeypair(&v2.LocalCredentialOptions_Keypair{}) + 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 TestCredentialIssuingUserBuilderRejectsFractionalDayTTL(t *testing.T) { +func TestCredentialIssuingUserBuilderRejectsPastExpiry(t *testing.T) { identityID, err := rs.NewResourceID(userResourceType, "svc_automation") require.NoError(t, err) builder := &credentialIssuingUserBuilder{ @@ -102,18 +100,49 @@ func TestCredentialIssuingUserBuilderRejectsFractionalDayTTL(t *testing.T) { 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.LocalCredentialOptions_Keypair_builder{ + keypair := v2.CredentialIssueOptions_Keypair_builder{ Profile: v2.KeyGenerationProfile_builder{Kty: "RSA", RsaModulusBits: &bits}.Build(), }.Build() - options := &v2.LocalCredentialOptions{} - options.SetKeypair(keypair) + options := v2.CredentialIssueOptions_builder{Keypair: keypair}.Build() _, err = builder.Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ - IdentityID: identityID, - CredentialOptions: options, - IssuanceConstraints: v2.CredentialIssuanceConstraints_builder{Lifetime: durationpb.New(36 * time.Hour)}.Build(), + IdentityID: identityID, + CredentialOptions: options, + ExpiresAt: timestamppb.New(time.Now().Add(-time.Hour)), }) - require.ErrorContains(t, err, "positive whole number of days") + 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") } diff --git a/pkg/snowflake/user.go b/pkg/snowflake/user.go index 2e45ee06..73bda1b8 100644 --- a/pkg/snowflake/user.go +++ b/pkg/snowflake/user.go @@ -191,6 +191,15 @@ func (c *Client) AddUserKeyPair(ctx context.Context, username, keyPairName, publ 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}) 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 b06fd2412e361396e5eff2c4486f1f4f3a611da6 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 17:40:55 +0000 Subject: [PATCH 8/9] fix(sync): tolerate unavailable named key pairs Co-authored-by: c1-squire-dev[bot] --- pkg/connector/named_key_pairs.go | 15 ++++++++++++--- pkg/connector/named_key_pairs_test.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pkg/connector/named_key_pairs.go b/pkg/connector/named_key_pairs.go index a9c745a4..c92848f0 100644 --- a/pkg/connector/named_key_pairs.go +++ b/pkg/connector/named_key_pairs.go @@ -17,12 +17,14 @@ import ( "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 { - client *snowflake.Client + listKeyPairs func(context.Context, string) ([]snowflake.NamedKeyPair, error) removeKeyPair func(context.Context, string, string) error } @@ -33,7 +35,10 @@ const ( ) func newNamedKeyPairBuilder(client *snowflake.Client) *namedKeyPairBuilder { - return &namedKeyPairBuilder{client: client, removeKeyPair: client.RemoveUserKeyPair} + return &namedKeyPairBuilder{ + listKeyPairs: client.ListUserKeyPairs, + removeKeyPair: client.RemoveUserKeyPair, + } } func (*namedKeyPairBuilder) ResourceType(context.Context) *v2.ResourceType { @@ -44,8 +49,12 @@ func (b *namedKeyPairBuilder) List(ctx context.Context, parent *v2.ResourceId, _ if parent == nil || parent.GetResourceType() != userResourceType.Id { return nil, nil, nil } - keyPairs, err := b.client.ListUserKeyPairs(ctx, parent.GetResource()) + 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)) diff --git a/pkg/connector/named_key_pairs_test.go b/pkg/connector/named_key_pairs_test.go index 3748fbab..7f27ceaf 100644 --- a/pkg/connector/named_key_pairs_test.go +++ b/pkg/connector/named_key_pairs_test.go @@ -14,6 +14,8 @@ import ( 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" ) @@ -146,3 +148,15 @@ func TestNamedKeyPairBuilderDeleteRejectsMismatchedParent(t *testing.T) { _, 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) +} From 75e5f0b580c83f912f120170313c61bb499dd855 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Thu, 23 Jul 2026 20:48:43 +0000 Subject: [PATCH 9/9] fix(sync): mark named key pairs opt-in Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 6 +- pkg/connector/connector_capabilities_test.go | 67 ++++++++++++++++++++ pkg/connector/resource_types.go | 8 ++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 pkg/connector/connector_capabilities_test.go diff --git a/baton_capabilities.json b/baton_capabilities.json index 1ceda7b0..fe0292dd 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -78,6 +78,9 @@ "annotations": [ { "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" } ] }, @@ -85,7 +88,8 @@ "CAPABILITY_SYNC", "CAPABILITY_RESOURCE_DELETE" ], - "permissions": {} + "permissions": {}, + "optInRequired": true }, { "resourceType": { 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/resource_types.go b/pkg/connector/resource_types.go index 11fea7f1..0e2a2142 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -43,7 +43,7 @@ var ( Id: "named_key_pair", DisplayName: "Named Key Pair", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, - Annotations: getSkipEntitlementsAnnotation(), + Annotations: getOptInSkipEntitlementsAnnotation(), } integrationResourceType = &v2.ResourceType{ Id: "integration", @@ -66,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{})