From 3431f75f939100ec6385483b256a9747117d04a7 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 17:50:37 +0000 Subject: [PATCH 1/2] nhi: sync programmatic access tokens as STATIC_SECRET resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add programmatic_access_token resource type (TRAIT_SECRET, CREDENTIAL_TYPE_STATIC_SECRET, detail "snowflake.pat") synced per user via SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER. Enumeration path: per-user fan-out over already-synced users calling SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER through the Statements API (same mechanism as SHOW SECRETS / DESCRIBE USER). The token value is never returned by Snowflake — only metadata. Gated behind the existing --sync-secrets flag, consistent with rsa_public_key and secret (#124). Users gain a second ChildResourceType annotation for programmatic_access_token when SyncSecrets is true. Doc refs (D-318): - https://docs.snowflake.com/en/sql-reference/sql/show-user-programmatic-access-tokens - https://docs.snowflake.com/en/sql-reference/account-usage/credentials Co-authored-by: c1-squire-dev[bot] --- pkg/connector/connector.go | 1 + pkg/connector/pat.go | 100 ++++++++++++++++++++++++++++++ pkg/connector/pat_test.go | 83 +++++++++++++++++++++++++ pkg/connector/resource_types.go | 6 ++ pkg/connector/users.go | 5 +- pkg/snowflake/pat.go | 104 ++++++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/pat.go create mode 100644 pkg/connector/pat_test.go create mode 100644 pkg/snowflake/pat.go 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() +} From 3d5860bdb6f5e285ff7f82692e75c311605c0933 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 10 Jun 2026 18:38:29 +0000 Subject: [PATCH 2/2] chore(nhi): regen baton_capabilities.json; add PAT to docs capabilities table New programmatic_access_token resource type (TRAIT_SECRET, CAPABILITY_SYNC) added in the NHI commit now appears in capabilities output. Commit the regenerated artifact and add a matching row to the connector docs table. Co-authored-by: c1-squire-dev[bot] --- baton_capabilities.json | 18 ++++++++++++++++++ docs/connector.mdx | 1 + 2 files changed, 19 insertions(+) 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).