From 186af51dd8a6997468d229140aaa5625db586059 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:50:05 +0000 Subject: [PATCH 1/4] feat(pat): configure credential issuance separately from secret syncing sync-secrets gated four things at once: the secret and RSA public key syncers, the programmatic access token syncer, the token child-resource annotation, and the credential issuer itself. A tenant that wanted a secret inventory had to grant token minting to get it, and a tenant that wanted issuance had to take a full secret inventory it may not want. issue-credentials splits the issuer out. The two flags are independent, with one deliberate overlap: the programmatic access token type is synced when either is set. Issuance advertises DISCOVERABLE, so enabling it has to make issued tokens syncable even with the broader secret sync off, or the credential exists with nothing holding a handle to revoke it. secretOptions.tokensSynced carries that rule in one place rather than repeating the disjunction at each site. | sync-secrets | issue-credentials | secret types synced | issuer | |---|---|---|---| | off | off | none | no | | on | off | secret, rsa_public_key, programmatic_access_token | no | | off | on | programmatic_access_token | yes | | on | on | all three | yes | Nothing regresses: credential issuance is not on main, so no tenant has it today, and sync-secrets keeps doing exactly what it does now. issue-credentials defaults to off, which is the right default for a capability that mints credentials. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- cmd/baton-snowflake/main.go | 2 +- docs/connector.mdx | 11 ++- pkg/config/config.go | 10 ++ pkg/connector/connector.go | 19 ++-- pkg/connector/connector_test.go | 98 +++++++++++++++++++ .../programmatic_access_tokens_test.go | 38 ++++--- pkg/connector/tables.go | 2 +- pkg/connector/tables_test.go | 1 - pkg/connector/users.go | 55 +++++++---- pkg/connector/users_test.go | 4 +- 10 files changed, 184 insertions(+), 56 deletions(-) diff --git a/cmd/baton-snowflake/main.go b/cmd/baton-snowflake/main.go index 2ad8f32b..48973ec8 100644 --- a/cmd/baton-snowflake/main.go +++ b/cmd/baton-snowflake/main.go @@ -23,6 +23,6 @@ func main() { cfg.ConfigurationSchema(), connector.New, connectorrunner.WithSessionStoreEnabled(), - connectorrunner.WithDefaultCapabilitiesConnectorBuilderV2(&connector.Connector{SyncSecrets: true}), + connectorrunner.WithDefaultCapabilitiesConnectorBuilderV2(&connector.Connector{SyncSecrets: true, IssueCredentials: true}), ) } diff --git a/docs/connector.mdx b/docs/connector.mdx index b912e582..4cc77e5d 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -28,7 +28,9 @@ The Snowflake connector supports [account provisioning](/product/admin/account-p ### Issuing programmatic access tokens -The connector can issue a Snowflake [programmatic access token](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) for an existing user, and can revoke one it has issued. Issued tokens are synced back as **Programmatic access token** resources, so they appear in your inventory alongside the user they belong to. +Enable **Issue credentials** to let the connector issue a Snowflake [programmatic access token](https://docs.snowflake.com/en/user-guide/programmatic-access-tokens) for an existing user, and revoke one it has issued. Issued tokens are synced back as **Programmatic access token** resources, so they appear in your inventory alongside the user they belong to. + +**Issue credentials** and **Sync secrets** are independent. Enabling **Issue credentials** syncs the programmatic access tokens the connector can revoke, and nothing else; enabling **Sync secrets** inventories Snowflake secrets and RSA public keys without granting the ability to mint tokens. Enable both if you want a full secret inventory and issuance. Token lifetime is set by the requester. Snowflake accepts whole days only, and the connector rounds down so a token never outlives the requested expiry. The minimum is one day and the maximum is one year; when no expiry is requested the token lasts 15 days. @@ -44,7 +46,7 @@ Token lifetime is set by the requester. Snowflake accepts whole days only, and t **License data is opt-in and requires an organization account.** License resources report the Snowflake edition (Standard, Enterprise, or Business Critical) and, for single-account organizations, the number of users as consumed seats. Reading it requires connecting with an account that can view organization-level details, so enable this capability only when that access is available. -[This connector can sync secrets](/product/admin/inventory) and display them on the **Inventory** page. +[This connector can sync secrets](/product/admin/inventory) and display them on the **Inventory** page, and can issue programmatic access tokens. Both are opt-in and configured separately. ### Connector actions @@ -174,7 +176,10 @@ In the **Username** field, enter your Snowflake username. In the **RSA Private Key (PEM Format)** field, upload the private key file. -**Optional.** Enable **Sync secrets** to display them on the [Inventory page](/product/admin/inventory). +**Optional.** Enable **Sync secrets** to inventory Snowflake secrets and RSA public keys on the [Inventory page](/product/admin/inventory). + + +**Optional.** Enable **Issue credentials** to allow issuing and revoking programmatic access tokens. This is separate from **Sync secrets** — see [Issuing programmatic access tokens](#issuing-programmatic-access-tokens) for the prerequisites. **Optional.** In the **Excluded Databases** field, enter the names of any Snowflake databases you want to skip during sync. You can add multiple names. Matching is case-insensitive. Excluded databases and all their tables are omitted from every sync. diff --git a/pkg/config/config.go b/pkg/config/config.go index 64de7d4c..7efa2661 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -45,6 +45,15 @@ var ( field.WithDescription("Enable synchronization of Snowflake secrets. When enabled, the connector will sync secrets from your Snowflake account."), field.WithDefaultValue(false), ) + IssueCredentials = field.BoolField( + "issue-credentials", + field.WithDisplayName("Issue Credentials"), + field.WithDescription( + "Enable issuing Snowflake programmatic access tokens for existing users. Independent of "+ + "Sync Secrets: this also syncs the tokens it issues so they can be revoked, but no other secrets.", + ), + field.WithDefaultValue(false), + ) ExcludedDatabases = field.StringSliceField( "excluded-databases", field.WithDisplayName("Excluded Databases"), @@ -69,6 +78,7 @@ var ( PrivateKeyPathField, UserIdentifierField, SyncSecrets, + IssueCredentials, ExcludedDatabases, } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f743ff89..c52c0dda 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -20,14 +20,16 @@ import ( type Connector struct { Client *snowflake.Client SyncSecrets bool + IssueCredentials bool excludedDatabases []string } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { - userSyncer := connectorbuilder.ResourceSyncerV2(newUserBuilder(d.Client, d.SyncSecrets)) - if d.SyncSecrets { - userSyncer = newCredentialUserBuilder(d.Client, d.SyncSecrets) + secrets := secretOptions{syncSecrets: d.SyncSecrets, issueCredentials: d.IssueCredentials} + userSyncer := connectorbuilder.ResourceSyncerV2(newUserBuilder(d.Client, secrets)) + if d.IssueCredentials { + userSyncer = newCredentialUserBuilder(d.Client, secrets) } builders := []connectorbuilder.ResourceSyncerV2{ userSyncer, @@ -39,12 +41,10 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso } if d.SyncSecrets { - builders = append( - builders, - newSecretBuilder(d.Client), - newRsaBuilder(d.Client), - newProgrammaticAccessTokenBuilder(d.Client), - ) + builders = append(builders, newSecretBuilder(d.Client), newRsaBuilder(d.Client)) + } + if secrets.tokensSynced() { + builders = append(builders, newProgrammaticAccessTokenBuilder(d.Client)) } return builders @@ -264,6 +264,7 @@ func New(ctx context.Context, cfg *config.Snowflake, _ *cli.ConnectorOpts) (conn return &Connector{ Client: client, SyncSecrets: cfg.SyncSecrets, + IssueCredentials: cfg.IssueCredentials, excludedDatabases: cfg.ExcludedDatabases, }, nil, nil } diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index b34b83ec..c654be7b 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -1,9 +1,14 @@ package connector import ( + "context" + "sort" "testing" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-snowflake/pkg/snowflake" + "github.com/stretchr/testify/require" ) func TestMissingLoginPrivilegeErr(t *testing.T) { @@ -35,3 +40,96 @@ func TestMissingLoginPrivilegeErr(t *testing.T) { }) } } + +// sync-secrets and issue-credentials are independent, but not unrelated: issuance +// advertises DISCOVERABLE, so turning it on has to make the token type syncable even +// when the broader secret sync is off. Otherwise an issued credential exists with +// nothing holding a handle to revoke it. +func TestSecretFlagsGateIndependently(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + syncSecrets bool + issueCredentials bool + wantIssuer bool + wantTypes []string + }{ + { + name: "neither", + wantTypes: []string{"account_role", "database", "integration", "license", "table", "user"}, + }, + { + name: "inventory without minting", + syncSecrets: true, + wantTypes: []string{ + "account_role", "database", "integration", "license", + "programmatic_access_token", "rsa_public_key", "secret", "table", "user", + }, + }, + { + name: "minting without a full inventory", + issueCredentials: true, + wantIssuer: true, + wantTypes: []string{ + "account_role", "database", "integration", "license", + "programmatic_access_token", "table", "user", + }, + }, + { + name: "both", + syncSecrets: true, + issueCredentials: true, + wantIssuer: true, + wantTypes: []string{ + "account_role", "database", "integration", "license", + "programmatic_access_token", "rsa_public_key", "secret", "table", "user", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server, err := connectorbuilder.NewConnector(context.Background(), &Connector{ + SyncSecrets: tc.syncSecrets, IssueCredentials: tc.issueCredentials, + }) + require.NoError(t, err) + response, err := server.GetMetadata(context.Background(), &v2.ConnectorServiceGetMetadataRequest{}) + require.NoError(t, err) + + gotTypes, gotIssuer := []string{}, false + for _, capability := range response.GetMetadata().GetCapabilities().GetResourceTypeCapabilities() { + gotTypes = append(gotTypes, capability.GetResourceType().GetId()) + if capability.GetResourceType().GetId() == userResourceType.Id && capability.GetCredentialIssue() != nil { + gotIssuer = true + } + } + sort.Strings(gotTypes) + require.Equal(t, tc.wantTypes, gotTypes) + require.Equal(t, tc.wantIssuer, gotIssuer, "credential issuance advertised") + + // The child annotations have to move with the resource types, or a synced + // type is registered but never walked per user. + resource, err := userResource(context.Background(), + &snowflake.User{Username: "service-user", Type: "SERVICE"}, + secretOptions{syncSecrets: tc.syncSecrets, issueCredentials: tc.issueCredentials}) + require.NoError(t, err) + children := []string{} + for _, annotation := range resource.GetAnnotations() { + child := &v2.ChildResourceType{} + if annotation.MessageIs(child) { + require.NoError(t, annotation.UnmarshalTo(child)) + children = append(children, child.GetResourceTypeId()) + } + } + sort.Strings(children) + want := []string{} + if tc.syncSecrets { + want = append(want, rsaPublicKeyResourceType.Id) + } + if tc.syncSecrets || tc.issueCredentials { + want = append(want, programmaticAccessTokenResourceType.Id) + } + sort.Strings(want) + require.Equal(t, want, children) + }) + } +} diff --git a/pkg/connector/programmatic_access_tokens_test.go b/pkg/connector/programmatic_access_tokens_test.go index d218097d..b83c41c8 100644 --- a/pkg/connector/programmatic_access_tokens_test.go +++ b/pkg/connector/programmatic_access_tokens_test.go @@ -28,7 +28,7 @@ func TestCredentialUserBuilderIssueServiceUserUsesDefaultRoleRestriction(t *test t.Fatalf("new client: %v", err) } - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -49,7 +49,7 @@ func TestCredentialUserBuilderIssueServiceUserWithUnassignedDefaultRoleFailsBefo t.Fatalf("new client: %v", err) } - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -212,7 +212,7 @@ func TestProgrammaticAccessTokenIDRoundTrip(t *testing.T) { } func TestCredentialIssuanceCapabilitiesRegisterWithDeleter(t *testing.T) { - server, err := connectorbuilder.NewConnector(context.Background(), &Connector{SyncSecrets: true}) + server, err := connectorbuilder.NewConnector(context.Background(), &Connector{IssueCredentials: true}) if err != nil { t.Fatalf("NewConnector() error = %v", err) } @@ -242,7 +242,7 @@ func TestCredentialIssuanceCapabilitiesRegisterWithDeleter(t *testing.T) { } func TestIssueCapabilityDetails(t *testing.T) { - details, _, err := newCredentialUserBuilder(nil, true).IssueCapabilityDetails(context.Background()) + details, _, err := newCredentialUserBuilder(nil, secretOptions{issueCredentials: true}).IssueCapabilityDetails(context.Background()) if err != nil { t.Fatalf("IssueCapabilityDetails() error = %v", err) } @@ -268,7 +268,7 @@ func TestCredentialUserBuilderIssueServiceUserWithNullDefaultRoleReportsMissingR t.Fatalf("new client: %v", err) } - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -292,7 +292,7 @@ func TestCredentialUserBuilderIssueRemovesTokenWhenProviderDoesNotReturnIt(t *te t.Fatalf("new client: %v", err) } - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -310,28 +310,26 @@ func TestCredentialUserBuilderIssueRemovesTokenWhenProviderDoesNotReturnIt(t *te func TestUserResourceAdvertisesTokenAsChildResourceType(t *testing.T) { // The syncer walks a child type per parent only when the parent carries this // annotation. Without it an issued token is never discovered by a sync, which - // contradicts the DISCOVERABLE mode the issuer advertises. - resource, err := userResource(context.Background(), &snowflake.User{Username: "service-user", Type: "SERVICE"}, true) + // contradicts the DISCOVERABLE mode the issuer advertises. issue-credentials alone + // must be enough: it is the flag that makes tokens exist in the first place. + resource, err := userResource(context.Background(), &snowflake.User{Username: "service-user", Type: "SERVICE"}, secretOptions{issueCredentials: true}) if err != nil { t.Fatalf("userResource() error = %v", err) } - want := map[string]bool{ - rsaPublicKeyResourceType.Id: false, - programmaticAccessTokenResourceType.Id: false, - } + found := false for _, annotation := range resource.GetAnnotations() { child := &v2.ChildResourceType{} if annotation.MessageIs(child) { if err := annotation.UnmarshalTo(child); err != nil { t.Fatalf("unmarshal child resource type: %v", err) } - want[child.GetResourceTypeId()] = true + if child.GetResourceTypeId() == programmaticAccessTokenResourceType.Id { + found = true + } } } - for id, found := range want { - if !found { - t.Fatalf("user resource is missing ChildResourceType %q", id) - } + if !found { + t.Fatalf("user resource is missing ChildResourceType %q", programmaticAccessTokenResourceType.Id) } } @@ -352,7 +350,7 @@ func TestCredentialUserBuilderIssueKeepsTokenWhenReadBackIsDenied(t *testing.T) t.Fatalf("new client: %v", err) } - output, err := newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + output, err := newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -391,7 +389,7 @@ func TestCredentialUserBuilderIssueProceedsWhenRoleCheckIsDenied(t *testing.T) { t.Fatalf("new client: %v", err) } - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", }) @@ -422,7 +420,7 @@ func TestCredentialUserBuilderIssueSamplesExpiryAfterPreflight(t *testing.T) { } requested := time.Now().UTC().Add(2*24*time.Hour + 50*time.Millisecond) - _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ + _, err = newCredentialUserBuilder(client, secretOptions{issueCredentials: true}).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(), RequestID: "request-1", ExpiresAt: timestamppb.New(requested), diff --git a/pkg/connector/tables.go b/pkg/connector/tables.go index 99712f60..31e29618 100644 --- a/pkg/connector/tables.go +++ b/pkg/connector/tables.go @@ -464,7 +464,7 @@ func (o *tableBuilder) Grants(ctx context.Context, resource *v2.Resource, opts r if user == nil { continue } - principalResource, err = userResource(ctx, user, false) + principalResource, err = userResource(ctx, user, secretOptions{}) if err != nil { return nil, nil, wrapError(err, fmt.Sprintf("failed to build resource for user %q", tg.GranteeName)) } diff --git a/pkg/connector/tables_test.go b/pkg/connector/tables_test.go index b2530119..d57f60b7 100644 --- a/pkg/connector/tables_test.go +++ b/pkg/connector/tables_test.go @@ -879,7 +879,6 @@ func newDatabaseGrantsStatusMockServer(t *testing.T, owner string, rolesStatus i })) } - // TestTableBuilder_List_EnumeratesSchemasWhenVisible is the control for the test above: it // proves the mock drives the real code path, so the 422 case cannot pass for the wrong reason. func TestTableBuilder_List_EnumeratesSchemasWhenVisible(t *testing.T) { diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 83de0e1d..f172c827 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -24,12 +24,29 @@ import ( type userBuilder struct { resourceType *v2.ResourceType client *snowflake.Client - syncSecrets bool + secrets secretOptions } -// credentialUserBuilder opts into credential issuance only when secret syncing -// is enabled. The issued-token resource type is therefore registered alongside -// the issuer, which is required by the SDK's build-time revoke validation. +// secretOptions carries the two independent gates for secret-bearing behaviour: +// sync-secrets lists secrets that already exist, and issue-credentials mints +// programmatic access tokens. A tenant may want inventory without minting, or +// minting without a full secret inventory, so neither implies the other. +type secretOptions struct { + syncSecrets bool + issueCredentials bool +} + +// tokensSynced reports whether the programmatic access token type is synced. +// Issuance advertises DISCOVERABLE, so turning it on has to make issued tokens +// syncable even when the broader secret sync is off; otherwise the credential +// exists with nothing holding a handle to revoke it. +func (s secretOptions) tokensSynced() bool { + return s.syncSecrets || s.issueCredentials +} + +// credentialUserBuilder adds credential issuance to the user syncer, and is +// registered only when issue-credentials is set. The issued-token resource type is +// registered alongside it, which the SDK's build-time revoke validation requires. type credentialUserBuilder struct { *userBuilder } @@ -42,8 +59,8 @@ const ( programmaticAccessTokenDefaultDays = 15 ) -func newCredentialUserBuilder(client *snowflake.Client, syncSecrets bool) *credentialUserBuilder { - return &credentialUserBuilder{userBuilder: newUserBuilder(client, syncSecrets)} +func newCredentialUserBuilder(client *snowflake.Client, secrets secretOptions) *credentialUserBuilder { + return &credentialUserBuilder{userBuilder: newUserBuilder(client, secrets)} } func (o *credentialUserBuilder) IssueCapabilityDetails(_ context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) { @@ -202,7 +219,7 @@ func (o *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return userResourceType } -func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v2.Resource, error) { +func userResource(_ context.Context, user *snowflake.User, secrets secretOptions) (*v2.Resource, error) { profile := map[string]interface{}{ "email": user.Email, "login": user.Login, @@ -238,15 +255,15 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v rs.WithResourceProfile(profile), rs.WithResourceStatus(getUserStatus(user), getUserDetailedStatus(user)), } - if syncSecrets { + if secrets.syncSecrets { + opts = append(opts, rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id})) + } + if secrets.tokensSynced() { // The syncer only calls a child type's List with a parent when the parent - // carries this annotation. Without the token entry an issued programmatic - // access token is never discovered by a sync, which contradicts the - // DISCOVERABLE resource mode the issuer advertises. - opts = append(opts, - rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id}), - rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: programmaticAccessTokenResourceType.Id}), - ) + // carries this annotation. Without it an issued programmatic access token is + // never discovered by a sync, which contradicts the DISCOVERABLE resource + // mode the issuer advertises. + opts = append(opts, rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: programmaticAccessTokenResourceType.Id})) } if nhiType, nhiDetail, isNHI := classifyUserNHI(user.Type); isNHI { opts = append(opts, rs.WithNHIType(nhiType, nhiDetail)) @@ -389,7 +406,7 @@ func (o *userBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, var resources []*v2.Resource for _, user := range users { - resource, err := userResource(ctx, &user, o.syncSecrets) // #nosec G601 + resource, err := userResource(ctx, &user, o.secrets) // #nosec G601 if err != nil { return nil, nil, wrapError(err, "failed to create user resource") } @@ -511,7 +528,7 @@ func (o *userBuilder) CreateAccount( } // Build resource for the new user - resource, err := userResource(ctx, user, o.syncSecrets) + resource, err := userResource(ctx, user, o.secrets) if err != nil { return nil, nil, nil, wrapError(err, "failed to create user resource") } @@ -629,10 +646,10 @@ func (o *userBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId, par return nil, nil } -func newUserBuilder(client *snowflake.Client, syncSecrets bool) *userBuilder { +func newUserBuilder(client *snowflake.Client, secrets secretOptions) *userBuilder { return &userBuilder{ resourceType: userResourceType, client: client, - syncSecrets: syncSecrets, + secrets: secrets, } } diff --git a/pkg/connector/users_test.go b/pkg/connector/users_test.go index b8568c9e..fca4fc1c 100644 --- a/pkg/connector/users_test.go +++ b/pkg/connector/users_test.go @@ -110,7 +110,7 @@ func TestUserResourceNHIAnnotation(t *testing.T) { ctx := context.Background() serviceUser := &snowflake.User{Username: "svc", Type: "SERVICE"} - res, err := userResource(ctx, serviceUser, false) + res, err := userResource(ctx, serviceUser, secretOptions{}) if err != nil { t.Fatalf("userResource() error = %v", err) } @@ -126,7 +126,7 @@ func TestUserResourceNHIAnnotation(t *testing.T) { } personUser := &snowflake.User{Username: "alice", Type: "PERSON"} - res, err = userResource(ctx, personUser, false) + res, err = userResource(ctx, personUser, secretOptions{}) if err != nil { t.Fatalf("userResource() error = %v", err) } From dc7a1372220d84af813cb72e9cfaa7f8fa78c2e2 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:50:05 +0000 Subject: [PATCH 2/4] chore(config): regenerate conf.gen.go for issue-credentials Generated by go run ./pkg/config/gen. Note that make generate does not do this: there is no //go:generate directive in pkg/config, so go generate ./pkg/config is a no-op, and the generator writes conf.gen.go to the repo root rather than into pkg/config. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- pkg/config/conf.gen.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index a8e30cbc..d84241ae 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -10,6 +10,7 @@ type Snowflake struct { PrivateKeyPath string `mapstructure:"private-key-path"` UserIdentifier string `mapstructure:"user-identifier"` SyncSecrets bool `mapstructure:"sync-secrets"` + IssueCredentials bool `mapstructure:"issue-credentials"` ExcludedDatabases []string `mapstructure:"excluded-databases"` } From 06caa76a588147f25aee4aff68bf38d5c5eb729e Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:52:09 +0000 Subject: [PATCH 3/4] chore(config): regenerate config_schema.json for issue-credentials Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- config_schema.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config_schema.json b/config_schema.json index 791950a7..637c3f47 100644 --- a/config_schema.json +++ b/config_schema.json @@ -145,6 +145,12 @@ "description": "Enable synchronization of Snowflake secrets. When enabled, the connector will sync secrets from your Snowflake account.", "boolField": {} }, + { + "name": "issue-credentials", + "displayName": "Issue Credentials", + "description": "Enable issuing Snowflake programmatic access tokens for existing users. Independent of Sync Secrets: this also syncs the tokens it issues so they can be revoked, but no other secrets.", + "boolField": {} + }, { "name": "excluded-databases", "displayName": "Excluded Databases", From 2c673e6e2b2b11e5b8bb8e009a664347f0919b68 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:53:40 +0000 Subject: [PATCH 4/4] docs(readme): list the issue-credentials flag The help block in the README is a hand-maintained snapshot that has drifted from the real --help output well beyond this flag: it is missing --auth-method, --health-check, --http-timeout-seconds, --storage-engine, --workers and the external-resource and otel flags, and several descriptions are stale. Nothing in CI gates it. Refreshing the whole block is left alone here rather than bundled into an unrelated change. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index be2f7b21..937b1c62 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,7 @@ Flags: --excluded-databases strings Database names to exclude from sync, case-insensitive. Can be specified multiple times. ($BATON_EXCLUDED_DATABASES) -f, --file string The path to the c1z file to sync with ($BATON_FILE) (default "sync.c1z") -h, --help help for baton-snowflake +--issue-credentials Enable issuing Snowflake programmatic access tokens for existing users. ($BATON_ISSUE_CREDENTIALS) --log-format string The output format for logs: json, console ($BATON_LOG_FORMAT) (default "json") --log-level string The log level: debug, info, warn, error ($BATON_LOG_LEVEL) (default "info") --private-key string Private Key (PEM format). ($BATON_PRIVATE_KEY)