diff --git a/baton_capabilities.json b/baton_capabilities.json
index c9d6f45f..b22ee1ea 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -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",
diff --git a/docs/connector.mdx b/docs/connector.mdx
index 97599089..b7c81c27 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -21,6 +21,7 @@ sidebarTitle: Snowflake
| Integrations | | |
| Secrets | | |
| RSA Public Keys | | |
+| Programmatic Access Tokens | | |
The Snowflake connector supports [account provisioning](/product/admin/account-provisioning).
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 2eee342e..35e1a2ea 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -36,6 +36,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
builders,
newSecretBuilder(d.Client),
newRsaBuilder(d.Client),
+ newPATBuilder(d.Client),
)
}
diff --git a/pkg/connector/pat.go b/pkg/connector/pat.go
new file mode 100644
index 00000000..4999e224
--- /dev/null
+++ b/pkg/connector/pat.go
@@ -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 "/" 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}
+}
diff --git a/pkg/connector/pat_test.go b/pkg/connector/pat_test.go
new file mode 100644
index 00000000..99e89dde
--- /dev/null
+++ b/pkg/connector/pat_test.go
@@ -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)
+}
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
index 29160902..35cceb27 100644
--- a/pkg/connector/resource_types.go
+++ b/pkg/connector/resource_types.go
@@ -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 {
diff --git a/pkg/connector/users.go b/pkg/connector/users.go
index 4b734c51..87fb3943 100644
--- a/pkg/connector/users.go
+++ b/pkg/connector/users.go
@@ -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(
diff --git a/pkg/snowflake/pat.go b/pkg/snowflake/pat.go
new file mode 100644
index 00000000..feb4f5ad
--- /dev/null
+++ b/pkg/snowflake/pat.go
@@ -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()
+}