Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/baton-snowflake/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
)
}
6 changes: 6 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
</Note>

[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

Expand Down Expand Up @@ -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.
</Step>
<Step>
**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).
</Step>
<Step>
**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.
</Step>
<Step>
**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.
Expand Down
1 change: 1 addition & 0 deletions pkg/config/conf.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -69,6 +78,7 @@ var (
PrivateKeyPathField,
UserIdentifierField,
SyncSecrets,
IssueCredentials,
ExcludedDatabases,
}

Expand Down
19 changes: 10 additions & 9 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
}
98 changes: 98 additions & 0 deletions pkg/connector/connector_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
})
}
}
38 changes: 18 additions & 20 deletions pkg/connector/programmatic_access_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand All @@ -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",
})
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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",
})
Expand All @@ -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",
})
Expand All @@ -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)
}
}

Expand All @@ -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",
})
Expand Down Expand Up @@ -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",
})
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion pkg/connector/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
1 change: 0 additions & 1 deletion pkg/connector/tables_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading