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
41 changes: 38 additions & 3 deletions baton_capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,25 @@
"permissions": {},
"optInRequired": true
},
{
"resourceType": {
"id": "programmatic_access_token",
"displayName": "Programmatic Access Token",
"traits": [
"TRAIT_SECRET"
],
"annotations": [
{
"@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants"
}
]
},
"capabilities": [
"CAPABILITY_SYNC",
"CAPABILITY_RESOURCE_DELETE"
],
"permissions": {}
},
{
"resourceType": {
"id": "rsa_public_key",
Expand Down Expand Up @@ -133,17 +152,33 @@
"capabilities": [
"CAPABILITY_SYNC",
"CAPABILITY_ACCOUNT_PROVISIONING",
"CAPABILITY_RESOURCE_DELETE"
"CAPABILITY_RESOURCE_DELETE",
"CAPABILITY_CREDENTIAL_ISSUE"
],
"permissions": {}
"permissions": {},
"credentialIssue": {
"options": [
{
"option": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN",
"expiry": {
"min": "86400s",
"max": "31536000s"
},
"resourceMode": "CREDENTIAL_RESOURCE_MODE_DISCOVERABLE",
"secretResourceTypeId": "programmatic_access_token"
}
],
"preferredOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN"
}
}
],
"connectorCapabilities": [
"CAPABILITY_PROVISION",
"CAPABILITY_SYNC",
"CAPABILITY_ACCOUNT_PROVISIONING",
"CAPABILITY_RESOURCE_DELETE",
"CAPABILITY_ACTIONS"
"CAPABILITY_ACTIONS",
"CAPABILITY_CREDENTIAL_ISSUE"
],
"credentialDetails": {
"capabilityAccountProvisioning": {
Expand Down
15 changes: 15 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,25 @@ sidebarTitle: Snowflake
| Integrations | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
| Secrets | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
| RSA Public Keys | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |
| Programmatic access tokens | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | <Icon icon="square-check" iconType="solid" color="#c937ae"/> |
| Licenses | <Icon icon="square-check" iconType="solid" color="#c937ae"/> | |

The Snowflake connector supports [account provisioning](/product/admin/account-provisioning).

### 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.

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.

<Warning>
**Snowflake requires a network policy on the user before it will issue a programmatic access token.** If the target user has no network policy attached, Snowflake rejects the request with `Network Policy is required when creating a programmatic access token for user <name>`. Attach a network policy to the user (or set one at the account level) before issuing. Creating a network policy requires a role with `CREATE NETWORK POLICY` on the account, which is more than the connector's own role needs.
</Warning>
Comment thread
c1-squire-dev[bot] marked this conversation as resolved.

<Note>
**Service users must have a granted default role.** For a user of type `SERVICE`, `SERVICE_AGENT`, or `LEGACY_SERVICE`, the connector restricts the issued token to the user's `DEFAULT_ROLE`, and verifies that role is granted to the user when its own role can read the user's grants. If the user has no default role, issuance fails before any token is created. If the default role is not granted, issuance fails either on that check or on Snowflake's own rejection of the statement.
</Note>

<Note>
**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>
Expand Down
7 changes: 6 additions & 1 deletion pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ type Connector struct {

// 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)
}
builders := []connectorbuilder.ResourceSyncerV2{
newUserBuilder(d.Client, d.SyncSecrets),
userSyncer,
newAccountRoleBuilder(d.Client),
newDatabaseBuilder(d.Client, d.SyncSecrets, d.excludedDatabases),
newTableBuilder(d.Client),
Expand All @@ -39,6 +43,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
builders,
newSecretBuilder(d.Client),
newRsaBuilder(d.Client),
newProgrammaticAccessTokenBuilder(d.Client),
)
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/connector/integrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func normalizeDetailToken(s string) string {
func integrationResource(integration *snowflake.Integration) (*v2.Resource, error) {
profile := map[string]interface{}{
profileKeyName: integration.Name,
"type": integration.Type,
profileKeyType: integration.Type,
"category": integration.Category,
profileKeyComment: integration.Comment,
}
Expand Down
1 change: 1 addition & 0 deletions pkg/connector/profile_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package connector
const (
profileKeyName = "name"
profileKeyComment = "comment"
profileKeyType = "type"
)
109 changes: 109 additions & 0 deletions pkg/connector/programmatic_access_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package connector

import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
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"
"go.uber.org/zap"
)

type programmaticAccessTokenBuilder struct {
client *snowflake.Client
}

func newProgrammaticAccessTokenBuilder(client *snowflake.Client) *programmaticAccessTokenBuilder {
return &programmaticAccessTokenBuilder{client: client}
}

func (o *programmaticAccessTokenBuilder) ResourceType(context.Context) *v2.ResourceType {
return programmaticAccessTokenResourceType
}

func (o *programmaticAccessTokenBuilder) List(ctx context.Context, parentID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
if parentID == nil || parentID.GetResourceType() != userResourceType.Id {
return nil, nil, nil
}
Comment thread
c1-squire-dev[bot] marked this conversation as resolved.
tokens, err := o.client.ListProgrammaticAccessTokens(ctx, parentID.GetResource())
if err != nil {
// SHOW USER PROGRAMMATIC ACCESS TOKENS needs ownership or MONITOR on the target
// user. Without it Snowflake answers 422/003001, which means "nothing visible
// here" rather than a failure - one unprivileged user must not abort the sync.
if snowflake.IsInsufficientPrivileges(err) {
ctxzap.Extract(ctx).Debug("skipping programmatic access tokens: insufficient privileges",
zap.String("username", parentID.GetResource()))
return nil, &rs.SyncOpResults{}, nil
}
return nil, nil, fmt.Errorf("baton-snowflake: list programmatic access tokens: %w", err)
}
resources := make([]*v2.Resource, 0, len(tokens))
for _, token := range tokens {
resource, err := newProgrammaticAccessTokenResource(parentID, token.Name, token.ExpiresAt)
if err != nil {
return nil, nil, err
}
resources = append(resources, resource)
}
return resources, nil, nil
}

func (o *programmaticAccessTokenBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) {
return nil, nil, nil
}

func (o *programmaticAccessTokenBuilder) Grants(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) {
return nil, nil, nil
}

func (o *programmaticAccessTokenBuilder) Delete(ctx context.Context, resourceID *v2.ResourceId, _ *v2.ResourceId) (annotations.Annotations, error) {
userName, tokenName, err := parseProgrammaticAccessTokenID(resourceID.GetResource())
if err != nil {
return nil, err
}
if err := o.client.RemoveProgrammaticAccessToken(ctx, userName, tokenName); err != nil {
return nil, fmt.Errorf("baton-snowflake: remove programmatic access token: %w", err)
}
return nil, nil
}

func newProgrammaticAccessTokenResource(identityID *v2.ResourceId, tokenName string, expiresAt time.Time) (*v2.Resource, error) {
return rs.NewSecretResource(
tokenName,
programmaticAccessTokenResourceType,
programmaticAccessTokenID(identityID.Resource, tokenName),
[]rs.SecretTraitOption{
rs.WithSecretCreatedByID(identityID),
rs.WithSecretIdentityID(identityID),
rs.WithSecretExpiresAt(expiresAt),
rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET),
rs.WithSecretDetail("snowflake.programmatic_access_token"),
},
rs.WithParentResourceID(identityID),
)
}

func programmaticAccessTokenID(userName, tokenName string) string {
return base64.RawURLEncoding.EncodeToString([]byte(userName)) + "." + tokenName
}

func parseProgrammaticAccessTokenID(resourceID string) (string, string, error) {
parts := strings.SplitN(resourceID, ".", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("baton-snowflake: invalid programmatic access token resource id")
}
userName, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(userName) == 0 {
return "", "", fmt.Errorf("baton-snowflake: invalid programmatic access token user id")
}
if strings.ContainsAny(parts[1], "\";") {
return "", "", fmt.Errorf("baton-snowflake: invalid programmatic access token name")
}
return string(userName), parts[1], nil
}
Loading
Loading