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
18 changes: 18 additions & 0 deletions baton_capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@
],
"permissions": {}
},
{
"resourceType": {
"id": "programmatic_access_token",
"displayName": "Programmatic Access Token",
"traits": [
"TRAIT_SECRET"
],
"annotations": [
{
"@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants"
}
]
},
"capabilities": [
"CAPABILITY_SYNC"
],
"permissions": {}
},
{
"resourceType": {
"id": "rsa_public_key",
Expand Down
1 change: 1 addition & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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"/> | |

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

Expand Down
1 change: 1 addition & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
builders,
newSecretBuilder(d.Client),
newRsaBuilder(d.Client),
newPATBuilder(d.Client),
)
}

Expand Down
100 changes: 100 additions & 0 deletions pkg/connector/pat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package connector

import (
"context"
"fmt"

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/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
)

type patBuilder struct {
client *snowflake.Client
}

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

// patResource builds a Secret resource from a single PAT metadata record.
// The resource ID is "<username>/<pat-name>" to namespace per user.
// Source: https://docs.snowflake.com/en/sql-reference/sql/show-user-programmatic-access-tokens
func patResource(_ context.Context, pat *snowflake.ProgrammaticAccessToken, parentID *v2.ResourceId) (*v2.Resource, error) {
userResourceID, err := rs.NewResourceID(userResourceType, pat.UserName)
if err != nil {
return nil, err
}

secretTraits := []rs.SecretTraitOption{
rs.WithSecretType(v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET),
rs.WithSecretDetail("snowflake.pat"),
rs.WithSecretIdentityID(userResourceID),
}

if !pat.CreatedOn.IsZero() {
secretTraits = append(secretTraits, rs.WithSecretCreatedAt(pat.CreatedOn))
}
if !pat.ExpiresAt.IsZero() {
secretTraits = append(secretTraits, rs.WithSecretExpiresAt(pat.ExpiresAt))
}

resourceID := fmt.Sprintf("%s/%s", pat.UserName, pat.Name)

return rs.NewSecretResource(
pat.Name,
programmaticAccessTokenResourceType,
resourceID,
secretTraits,
rs.WithParentResourceID(parentID),
)
}

// List returns all PATs for the user identified by parentResourceID.
// Parent must be a user resource; the connector iterates users and fans out
// one SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER call per user.
func (o *patBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) {
l := ctxzap.Extract(ctx)

if parentResourceID == nil {
return nil, nil, nil
}

if parentResourceID.ResourceType != userResourceType.Id {
return nil, nil, fmt.Errorf("invalid parent resource type: %s", parentResourceID.ResourceType)
}

username := parentResourceID.Resource

pats, err := o.client.ListProgrammaticAccessTokens(ctx, username)
if err != nil {
return nil, nil, err
}

l.Debug("listed PATs for user", zap.String("username", username), zap.Int("count", len(pats)))

var resources []*v2.Resource
for i := range pats {
resource, err := patResource(ctx, &pats[i], parentResourceID)
if err != nil {
return nil, nil, err
}
resources = append(resources, resource)
}

return resources, nil, nil
}

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

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

func newPATBuilder(client *snowflake.Client) *patBuilder {
return &patBuilder{client: client}
}
83 changes: 83 additions & 0 deletions pkg/connector/pat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package connector

import (
"context"
"testing"
"time"

rs "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/conductorone/baton-snowflake/pkg/snowflake"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPatResource_BasicFields(t *testing.T) {
ctx := context.Background()
createdOn := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
expiresAt := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)

pat := &snowflake.ProgrammaticAccessToken{
Name: "my_pat",
UserName: "ALICE",
Status: "ACTIVE",
CreatedOn: createdOn,
ExpiresAt: expiresAt,
}

parentID, err := rs.NewResourceID(userResourceType, "ALICE")
require.NoError(t, err)

resource, err := patResource(ctx, pat, parentID)
require.NoError(t, err)
require.NotNil(t, resource)

assert.Equal(t, "my_pat", resource.DisplayName)
assert.Equal(t, "ALICE/my_pat", resource.Id.Resource)
assert.Equal(t, programmaticAccessTokenResourceType.Id, resource.Id.ResourceType)
}

func TestPatResource_ZeroTimesOmitted(t *testing.T) {
ctx := context.Background()

pat := &snowflake.ProgrammaticAccessToken{
Name: "no_ts_pat",
UserName: "BOB",
Status: "ACTIVE",
// CreatedOn and ExpiresAt are zero β€” should not be emitted
}

parentID, err := rs.NewResourceID(userResourceType, "BOB")
require.NoError(t, err)

resource, err := patResource(ctx, pat, parentID)
require.NoError(t, err)
require.NotNil(t, resource)
assert.Equal(t, "no_ts_pat", resource.DisplayName)
}

func TestPatBuilder_ParentTypeMismatch(t *testing.T) {
ctx := context.Background()
builder := newPATBuilder(nil)

wrongParent, err := rs.NewResourceID(databaseResourceType, "MY_DB")
require.NoError(t, err)

_, _, err = builder.List(ctx, wrongParent, rs.SyncOpAttrs{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid parent resource type")
}

func TestPatBuilder_NilParent(t *testing.T) {
ctx := context.Background()
builder := newPATBuilder(nil)

resources, results, err := builder.List(ctx, nil, rs.SyncOpAttrs{})
require.NoError(t, err)
assert.Nil(t, resources)
assert.Nil(t, results)
}

func TestPATResourceType(t *testing.T) {
assert.Equal(t, "programmatic_access_token", programmaticAccessTokenResourceType.Id)
assert.Equal(t, "Programmatic Access Token", programmaticAccessTokenResourceType.DisplayName)
}
6 changes: 6 additions & 0 deletions pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ var (
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP},
Annotations: getSkipEntitlementsAnnotation(),
}
programmaticAccessTokenResourceType = &v2.ResourceType{
Id: "programmatic_access_token",
DisplayName: "Programmatic Access Token",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
Annotations: getSkipEntitlementsAnnotation(),
}
)

func getSkipEntitlementsAnnotation() annotations.Annotations {
Expand Down
5 changes: 4 additions & 1 deletion pkg/connector/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v

var opts []rs.ResourceOption
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: programmaticAccessTokenResourceType.Id}),
)
}

resource, err := rs.NewUserResource(
Expand Down
104 changes: 104 additions & 0 deletions pkg/snowflake/pat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package snowflake

import (
"context"
"fmt"
"time"

"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
)

// patStructFieldToColumnMap maps ProgrammaticAccessToken field names to the
// column names returned by SHOW USER PROGRAMMATIC ACCESS TOKENS.
// Source: https://docs.snowflake.com/en/sql-reference/sql/show-user-programmatic-access-tokens
var patStructFieldToColumnMap = map[string]string{
"Name": "name",
"UserName": "user_name",
"RoleRestriction": "role_restriction",
"ExpiresAt": "expires_at",
"Status": "status",
"Comment": "comment",
"CreatedOn": "created_on",
"CreatedBy": "created_by",
}

// ProgrammaticAccessToken holds the metadata returned by
// SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER.
// The token secret value is never returned by Snowflake.
// Source: https://docs.snowflake.com/en/sql-reference/sql/show-user-programmatic-access-tokens
type ProgrammaticAccessToken struct {
Name string
UserName string
RoleRestriction string
ExpiresAt time.Time
Status string
Comment string
CreatedOn time.Time
CreatedBy string
}

// GetColumnName implements Parsable.
func (p *ProgrammaticAccessToken) GetColumnName(fieldName string) string {
return patStructFieldToColumnMap[fieldName]
}

// ListPATsRawResponse wraps the Snowflake Statements API response for
// SHOW USER PROGRAMMATIC ACCESS TOKENS.
type ListPATsRawResponse struct {
StatementsApiResponseBase
}

func (r *ListPATsRawResponse) ListPATs() ([]ProgrammaticAccessToken, error) {
var pats []ProgrammaticAccessToken
for _, row := range r.Data {
pat := &ProgrammaticAccessToken{}
if err := r.ResultSetMetadata.ParseRow(pat, row); err != nil {
return nil, err
}
pats = append(pats, *pat)
}
return pats, nil
}

// ListProgrammaticAccessTokens issues SHOW USER PROGRAMMATIC ACCESS TOKENS
// FOR USER and returns all PATs for the given Snowflake user.
//
// Required privilege: MODIFY on the user object (USERADMIN / SECURITYADMIN
// satisfy this transitively).
// Source: https://docs.snowflake.com/en/sql-reference/sql/show-user-programmatic-access-tokens
//
// The token secret value is never returned β€” only metadata is enumerable.
func (c *Client) ListProgrammaticAccessTokens(ctx context.Context, username string) ([]ProgrammaticAccessToken, error) {
l := ctxzap.Extract(ctx)

escapedUsername := escapeDoubleQuotedIdentifier(username)
queries := []string{
fmt.Sprintf(`SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER "%s";`, escapedUsername),
}

req, err := c.PostStatementRequest(ctx, queries)
if err != nil {
return nil, err
}

var response ListPATsRawResponse
resp, err := c.Do(req, uhttp.WithJSONResponse(&response))
defer closeResponseBody(resp)
if err != nil {
statusCode := 0
if resp != nil {
statusCode = resp.StatusCode
}
if IsUnprocessableEntity(statusCode, err) {
// MODIFY privilege not held for this user β€” skip silently.
l.Debug("insufficient privileges for PAT enumeration; skipping user",
zap.String("username", username), zap.Error(err))
return nil, nil
}
return nil, err
}

return response.ListPATs()
}
Loading