diff --git a/baton_capabilities.json b/baton_capabilities.json
index dd39549b..3c544535 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -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",
@@ -133,9 +152,24 @@
"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": [
@@ -143,7 +177,8 @@
"CAPABILITY_SYNC",
"CAPABILITY_ACCOUNT_PROVISIONING",
"CAPABILITY_RESOURCE_DELETE",
- "CAPABILITY_ACTIONS"
+ "CAPABILITY_ACTIONS",
+ "CAPABILITY_CREDENTIAL_ISSUE"
],
"credentialDetails": {
"capabilityAccountProvisioning": {
diff --git a/docs/connector.mdx b/docs/connector.mdx
index f4eb8d21..b912e582 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -21,10 +21,25 @@ sidebarTitle: Snowflake
| Integrations | | |
| Secrets | | |
| RSA Public Keys | | |
+| Programmatic access tokens | | |
| Licenses | | |
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.
+
+
+**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 `. 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.
+
+
+
+**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.
+
+
**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.
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index df5dad42..f743ff89 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -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),
@@ -39,6 +43,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso
builders,
newSecretBuilder(d.Client),
newRsaBuilder(d.Client),
+ newProgrammaticAccessTokenBuilder(d.Client),
)
}
diff --git a/pkg/connector/integrations.go b/pkg/connector/integrations.go
index 54f1e370..d1e6db38 100644
--- a/pkg/connector/integrations.go
+++ b/pkg/connector/integrations.go
@@ -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,
}
diff --git a/pkg/connector/profile_keys.go b/pkg/connector/profile_keys.go
index 8511068c..d690613f 100644
--- a/pkg/connector/profile_keys.go
+++ b/pkg/connector/profile_keys.go
@@ -3,4 +3,5 @@ package connector
const (
profileKeyName = "name"
profileKeyComment = "comment"
+ profileKeyType = "type"
)
diff --git a/pkg/connector/programmatic_access_tokens.go b/pkg/connector/programmatic_access_tokens.go
new file mode 100644
index 00000000..371f079f
--- /dev/null
+++ b/pkg/connector/programmatic_access_tokens.go
@@ -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
+ }
+ 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
+}
diff --git a/pkg/connector/programmatic_access_tokens_test.go b/pkg/connector/programmatic_access_tokens_test.go
new file mode 100644
index 00000000..d218097d
--- /dev/null
+++ b/pkg/connector/programmatic_access_tokens_test.go
@@ -0,0 +1,436 @@
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
+ "github.com/conductorone/baton-sdk/pkg/annotations"
+ "github.com/conductorone/baton-sdk/pkg/connectorbuilder"
+ "github.com/conductorone/baton-snowflake/pkg/snowflake"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func TestCredentialUserBuilderIssueServiceUserUsesDefaultRoleRestriction(t *testing.T) {
+ var statements []string
+ server := newCredentialIssueMockServer(t, "SERVICE", "service_role", true, &statements)
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err != nil {
+ t.Fatalf("Issue() error = %v", err)
+ }
+ if !containsStatement(statements, `ROLE_RESTRICTION = "service_role"`) {
+ t.Fatalf("issuance statement did not restrict the token to the service user's default role: %q", statements)
+ }
+}
+
+func TestCredentialUserBuilderIssueServiceUserWithUnassignedDefaultRoleFailsBeforeTokenCreation(t *testing.T) {
+ var statements []string
+ server := newCredentialIssueMockServer(t, "SERVICE", "service_role", false, &statements)
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err == nil || !strings.Contains(err.Error(), "is not granted to the user") {
+ t.Fatalf("Issue() error = %v, want actionable missing-role error", err)
+ }
+ if containsStatement(statements, "ADD PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("Issue() created a token despite having no suitable role: %q", statements)
+ }
+}
+
+type credentialIssueMock struct {
+ userType string
+ defaultRole string
+ roleGranted bool
+ // showTokenName lets a test make SHOW return a name that does not match the token
+ // just created, which is the "provider did not return the token" failure path.
+ showTokenName string
+ // denyPrefix makes every statement with this prefix answer 422/003001, the shape
+ // Snowflake uses for an access-control denial.
+ denyPrefix string
+ // preflightDelay is spent inside DESCRIBE USER, standing in for the round-trip
+ // latency between sampling the clock and issuing the ALTER USER.
+ preflightDelay time.Duration
+ // liveExpiry makes SHOW derive expires_at from the mock's own clock and the
+ // statement's DAYS_TO_EXPIRY, the way Snowflake does, instead of a fixed instant.
+ liveExpiry bool
+ statements *[]string
+}
+
+func newCredentialIssueMockServer(t *testing.T, userType, defaultRole string, roleGranted bool, statements *[]string) *httptest.Server {
+ return newCredentialIssueMockServerWithShowName(t, userType, defaultRole, roleGranted, "c1-request-1", statements)
+}
+
+func newCredentialIssueMockServerWithShowName(t *testing.T, userType, defaultRole string, roleGranted bool, showTokenName string, statements *[]string) *httptest.Server {
+ return serveCredentialIssueMock(t, credentialIssueMock{
+ userType: userType, defaultRole: defaultRole, roleGranted: roleGranted,
+ showTokenName: showTokenName, statements: statements,
+ })
+}
+
+func serveCredentialIssueMock(t *testing.T, mock credentialIssueMock) *httptest.Server {
+ t.Helper()
+ var days int64 = 1
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL)
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("read request body: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ var request snowflake.StatementsApiRequestBody
+ if err := json.Unmarshal(body, &request); err != nil {
+ t.Errorf("decode request: %v", err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ *mock.statements = append(*mock.statements, request.Statement)
+ w.Header().Set("Content-Type", "application/json")
+ if mock.denyPrefix != "" && strings.HasPrefix(request.Statement, mock.denyPrefix) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "code": "003001",
+ "message": "SQL access control error: Insufficient privileges to operate on user",
+ })
+ return
+ }
+
+ switch {
+ case strings.HasPrefix(request.Statement, "DESCRIBE USER"):
+ time.Sleep(mock.preflightDelay)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{"numRows": 12},
+ "data": [][]string{
+ {"NAME", "service-user"}, {"LOGIN_NAME", "service-user"}, {"DISPLAY_NAME", "Service User"},
+ {"FIRST_NAME", ""}, {"LAST_NAME", ""}, {"EMAIL", ""}, {"DISABLED", "false"},
+ {"SNOWFLAKE_LOCK", "false"}, {"DEFAULT_ROLE", mock.defaultRole}, {"TYPE", mock.userType},
+ {"HAS_MFA", "false"}, {"COMMENT", ""},
+ },
+ })
+ case strings.HasPrefix(request.Statement, "SHOW GRANTS TO USER"):
+ data := [][]string{}
+ if mock.roleGranted {
+ data = append(data, []string{"ROLE", mock.defaultRole})
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{
+ "numRows": len(data),
+ "rowType": []map[string]any{{"name": "granted_on", "type": "text"}, {"name": "name", "type": "text"}},
+ },
+ "data": data,
+ })
+ case strings.Contains(request.Statement, "ADD PROGRAMMATIC ACCESS TOKEN"):
+ _, clause, found := strings.Cut(request.Statement, "DAYS_TO_EXPIRY = ")
+ if !found {
+ t.Errorf("no DAYS_TO_EXPIRY in %q", request.Statement)
+ } else if _, err := fmt.Sscanf(clause, "%d;", &days); err != nil {
+ t.Errorf("parse DAYS_TO_EXPIRY from %q: %v", request.Statement, err)
+ }
+ // Column metadata matches a live Snowflake response: cols are
+ // [token_name, token_secret]. The secret is read by name, not position.
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{
+ "numRows": 1,
+ "rowType": []map[string]any{
+ {"name": "token_name", "type": "text"},
+ {"name": "token_secret", "type": "text"},
+ },
+ },
+ "data": [][]string{{"C1_REQUEST_1", "redacted"}},
+ })
+ case strings.HasPrefix(request.Statement, "SHOW USER PROGRAMMATIC ACCESS TOKENS"):
+ expiresAt := "1893456000"
+ if mock.liveExpiry {
+ // Fractional seconds, the way Snowflake reports a TIMESTAMP_LTZ. Truncating
+ // to whole seconds would hide sub-second clock drift, which is the whole
+ // quantity the expiry-sampling test measures.
+ expiresAt = strconv.FormatFloat(
+ float64(time.Now().UTC().AddDate(0, 0, int(days)).UnixNano())/1e9, 'f', 6, 64,
+ )
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{
+ "numRows": 1,
+ "rowType": []map[string]any{{"name": "name", "type": "text"}, {"name": "expires_at", "type": "timestamp_ltz"}},
+ },
+ "data": [][]string{{mock.showTokenName, expiresAt}},
+ })
+ case strings.Contains(request.Statement, "REMOVE PROGRAMMATIC ACCESS TOKEN"):
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": [][]string{}})
+ default:
+ t.Errorf("unexpected statement: %s", request.Statement)
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+}
+
+func containsStatement(statements []string, want string) bool {
+ for _, statement := range statements {
+ if strings.Contains(statement, want) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestProgrammaticAccessTokenIDRoundTrip(t *testing.T) {
+ userName, tokenName, err := parseProgrammaticAccessTokenID(programmaticAccessTokenID(`Mixed Case User`, "c1-request_1"))
+ if err != nil {
+ t.Fatalf("parseProgrammaticAccessTokenID() error = %v", err)
+ }
+ if userName != `Mixed Case User` || tokenName != "c1-request_1" {
+ t.Fatalf("round trip = (%q, %q), want (%q, %q)", userName, tokenName, `Mixed Case User`, "c1-request_1")
+ }
+}
+
+func TestCredentialIssuanceCapabilitiesRegisterWithDeleter(t *testing.T) {
+ server, err := connectorbuilder.NewConnector(context.Background(), &Connector{SyncSecrets: true})
+ if err != nil {
+ t.Fatalf("NewConnector() error = %v", err)
+ }
+
+ response, err := server.GetMetadata(context.Background(), &v2.ConnectorServiceGetMetadataRequest{})
+ if err != nil {
+ t.Fatalf("GetMetadata() error = %v", err)
+ }
+ for _, capability := range response.GetMetadata().GetCapabilities().GetResourceTypeCapabilities() {
+ if capability.GetResourceType().GetId() != userResourceType.Id {
+ continue
+ }
+ details := capability.GetCredentialIssue()
+ if details == nil || len(details.GetOptions()) != 1 {
+ t.Fatalf("credential issue details = %#v, want one option", details)
+ }
+ descriptor := details.GetOptions()[0]
+ if descriptor.GetSecretResourceTypeId() != programmaticAccessTokenResourceType.Id {
+ t.Fatalf("secret resource type = %q, want %q", descriptor.GetSecretResourceTypeId(), programmaticAccessTokenResourceType.Id)
+ }
+ if descriptor.GetExpiry().GetMin().AsDuration() != programmaticAccessTokenMinLifetime || descriptor.GetExpiry().GetMax().AsDuration() != programmaticAccessTokenMaxLifetime {
+ t.Fatalf("expiry = %#v, want min %v and max %v", descriptor.GetExpiry(), programmaticAccessTokenMinLifetime, programmaticAccessTokenMaxLifetime)
+ }
+ return
+ }
+ t.Fatal("user resource type capability not found")
+}
+
+func TestIssueCapabilityDetails(t *testing.T) {
+ details, _, err := newCredentialUserBuilder(nil, true).IssueCapabilityDetails(context.Background())
+ if err != nil {
+ t.Fatalf("IssueCapabilityDetails() error = %v", err)
+ }
+ descriptor := details.GetOptions()[0]
+ if descriptor.GetOption() != v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN ||
+ descriptor.GetResourceMode() != v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE {
+ t.Fatalf("descriptor = %#v", descriptor)
+ }
+ if descriptor.GetExpiry().GetMin().AsDuration() != programmaticAccessTokenMinLifetime {
+ t.Fatalf("minimum expiry = %v", descriptor.GetExpiry().GetMin())
+ }
+}
+
+func TestCredentialUserBuilderIssueServiceUserWithNullDefaultRoleReportsMissingRole(t *testing.T) {
+ // The SQL API returns every column as text, so an unset DEFAULT_ROLE arrives as the
+ // literal "null". Testing the raw string for emptiness never matches, which used to
+ // send the caller to the "not granted" branch and tell them to grant a role named null.
+ var statements []string
+ server := newCredentialIssueMockServer(t, "SERVICE", "null", false, &statements)
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err == nil || !strings.Contains(err.Error(), "has no default role") {
+ t.Fatalf("Issue() error = %v, want the missing-default-role error", err)
+ }
+ if containsStatement(statements, "ADD PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("Issue() created a token despite the user having no default role: %q", statements)
+ }
+}
+
+func TestCredentialUserBuilderIssueRemovesTokenWhenProviderDoesNotReturnIt(t *testing.T) {
+ // Every failure after creation must remove the token. Otherwise the plaintext is
+ // discarded, no secret resource is recorded, the SDK does not retry, and the
+ // credential is left live with nothing holding a handle to revoke it.
+ var statements []string
+ server := newCredentialIssueMockServerWithShowName(t, "SERVICE", "service_role", true, "some-other-token", &statements)
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err == nil {
+ t.Fatal("Issue() error = nil, want failure when the provider does not return the token")
+ }
+ if !containsStatement(statements, "ADD PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("test did not reach token creation: %q", statements)
+ }
+ if !containsStatement(statements, "REMOVE PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("Issue() left the created token orphaned: %q", statements)
+ }
+}
+
+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)
+ if err != nil {
+ t.Fatalf("userResource() error = %v", err)
+ }
+ want := map[string]bool{
+ rsaPublicKeyResourceType.Id: false,
+ programmaticAccessTokenResourceType.Id: 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
+ }
+ }
+ for id, found := range want {
+ if !found {
+ t.Fatalf("user resource is missing ChildResourceType %q", id)
+ }
+ }
+}
+
+func TestCredentialUserBuilderIssueKeepsTokenWhenReadBackIsDenied(t *testing.T) {
+ // SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER needs ownership or MONITOR on the
+ // target user; creating the token does not. A role holding one and not the other
+ // would otherwise create a good credential and immediately destroy it, so issuance
+ // could never succeed for that tenant.
+ var statements []string
+ server := serveCredentialIssueMock(t, credentialIssueMock{
+ userType: "SERVICE", defaultRole: "service_role", roleGranted: true,
+ showTokenName: "c1-request-1", denyPrefix: "SHOW USER PROGRAMMATIC ACCESS TOKENS",
+ statements: &statements,
+ })
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ output, err := newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err != nil {
+ t.Fatalf("Issue() error = %v, want issuance to survive a read-back denial", err)
+ }
+ if containsStatement(statements, "REMOVE PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("Issue() destroyed a good credential over a read-back denial: %q", statements)
+ }
+ // Falls back to the locally computed expiry, which is Snowflake's own arithmetic
+ // from a slightly earlier clock and so is never later than the real one.
+ trait := &v2.SecretTrait{}
+ annos := annotations.Annotations(output.Secret.GetAnnotations())
+ if ok, err := annos.Pick(trait); err != nil || !ok {
+ t.Fatalf("secret trait: ok = %v, err = %v", ok, err)
+ }
+ want := time.Now().UTC().AddDate(0, 0, programmaticAccessTokenDefaultDays)
+ got := trait.GetExpiresAt().AsTime()
+ if got.Sub(want) > time.Minute || want.Sub(got) > time.Minute {
+ t.Fatalf("expiry = %v, want approximately %v", got, want)
+ }
+}
+
+func TestCredentialUserBuilderIssueProceedsWhenRoleCheckIsDenied(t *testing.T) {
+ // The default-role pre-check only turns a Snowflake rejection into a better
+ // message. A role that cannot run SHOW GRANTS TO USER must still be able to issue.
+ var statements []string
+ server := serveCredentialIssueMock(t, credentialIssueMock{
+ userType: "SERVICE", defaultRole: "service_role", roleGranted: true,
+ showTokenName: "c1-request-1", denyPrefix: "SHOW GRANTS TO USER",
+ statements: &statements,
+ })
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ })
+ if err != nil {
+ t.Fatalf("Issue() error = %v, want issuance to survive a role-check denial", err)
+ }
+ if !containsStatement(statements, `ROLE_RESTRICTION = "service_role"`) {
+ t.Fatalf("issuance dropped the role restriction it could not verify: %q", statements)
+ }
+}
+
+func TestCredentialUserBuilderIssueSamplesExpiryAfterPreflight(t *testing.T) {
+ // Snowflake derives the expiry from its own clock at ALTER USER time. Sampling
+ // before DESCRIBE USER and SHOW GRANTS makes the real expiry later than the one
+ // computed here by however long those took, so a request whose remaining time sits
+ // just above a whole number of days trips the "provider expiry exceeds requested"
+ // guard and destroys a valid token.
+ var statements []string
+ server := serveCredentialIssueMock(t, credentialIssueMock{
+ userType: "SERVICE", defaultRole: "service_role", roleGranted: true,
+ showTokenName: "c1-request-1", preflightDelay: 150 * time.Millisecond,
+ liveExpiry: true, statements: &statements,
+ })
+ defer server.Close()
+ client, err := snowflake.New(server.URL, snowflake.JWTConfig{}, server.Client())
+ if err != nil {
+ t.Fatalf("new client: %v", err)
+ }
+
+ requested := time.Now().UTC().Add(2*24*time.Hour + 50*time.Millisecond)
+ _, err = newCredentialUserBuilder(client, true).Issue(context.Background(), &connectorbuilder.CredentialIssueInput{
+ IdentityID: v2.ResourceId_builder{ResourceType: userResourceType.Id, Resource: "service-user"}.Build(),
+ RequestID: "request-1",
+ ExpiresAt: timestamppb.New(requested),
+ })
+ if err != nil {
+ t.Fatalf("Issue() error = %v, want a shorter token rather than a failed issuance", err)
+ }
+ if containsStatement(statements, "REMOVE PROGRAMMATIC ACCESS TOKEN") {
+ t.Fatalf("Issue() created and then destroyed a token: %q", statements)
+ }
+}
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
index 5436b11d..b485ee4e 100644
--- a/pkg/connector/resource_types.go
+++ b/pkg/connector/resource_types.go
@@ -39,6 +39,12 @@ var (
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
Annotations: getSkipEntitlementsAnnotation(),
}
+ programmaticAccessTokenResourceType = &v2.ResourceType{
+ Id: "programmatic_access_token",
+ DisplayName: "Programmatic Access Token",
+ Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET},
+ Annotations: getSkipEntitlementsAnnotation(),
+ }
integrationResourceType = &v2.ResourceType{
Id: "integration",
DisplayName: "Integration",
diff --git a/pkg/connector/users.go b/pkg/connector/users.go
index 6ac54840..83de0e1d 100644
--- a/pkg/connector/users.go
+++ b/pkg/connector/users.go
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/types/known/durationpb"
)
type userBuilder struct {
@@ -26,6 +27,177 @@ type userBuilder struct {
syncSecrets bool
}
+// credentialUserBuilder opts into credential issuance only when secret syncing
+// is enabled. The issued-token resource type is therefore registered alongside
+// the issuer, which is required by the SDK's build-time revoke validation.
+type credentialUserBuilder struct {
+ *userBuilder
+}
+
+var _ connectorbuilder.CredentialIssuerV2 = (*credentialUserBuilder)(nil)
+
+const (
+ programmaticAccessTokenMinLifetime = 24 * time.Hour
+ programmaticAccessTokenMaxLifetime = 365 * 24 * time.Hour
+ programmaticAccessTokenDefaultDays = 15
+)
+
+func newCredentialUserBuilder(client *snowflake.Client, syncSecrets bool) *credentialUserBuilder {
+ return &credentialUserBuilder{userBuilder: newUserBuilder(client, syncSecrets)}
+}
+
+func (o *credentialUserBuilder) IssueCapabilityDetails(_ context.Context) (*v2.CredentialDetailsCredentialIssue, annotations.Annotations, error) {
+ return v2.CredentialDetailsCredentialIssue_builder{
+ Options: []*v2.CredentialIssueOptionDescriptor{
+ v2.CredentialIssueOptionDescriptor_builder{
+ Option: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN,
+ Expiry: v2.IssuanceExpiryCapability_builder{
+ Min: durationpb.New(programmaticAccessTokenMinLifetime),
+ Max: durationpb.New(programmaticAccessTokenMaxLifetime),
+ }.Build(),
+ ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE,
+ SecretResourceTypeId: programmaticAccessTokenResourceType.Id,
+ }.Build(),
+ },
+ PreferredOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN,
+ }.Build(), nil, nil
+}
+
+func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuilder.CredentialIssueInput) (*connectorbuilder.CredentialIssueOutput, error) {
+ if input == nil || input.IdentityID == nil || input.IdentityID.ResourceType != userResourceType.Id || input.IdentityID.Resource == "" {
+ return nil, fmt.Errorf("baton-snowflake: a Snowflake user identity is required")
+ }
+
+ tokenName := "c1-" + input.RequestID
+ user, _, err := o.client.GetUser(ctx, nil, input.IdentityID.Resource)
+ if err != nil {
+ return nil, fmt.Errorf("baton-snowflake: get user for programmatic access token: %w", err)
+ }
+ roleRestriction := ""
+ if isServiceUserType(user.Type) {
+ // DESCRIBE USER renders an unset DEFAULT_ROLE as Snowflake's textual NULL,
+ // not as an empty string, so this must normalize before testing for absence.
+ roleRestriction = snowflake.NormalizeNullValue(user.DefaultRole)
+ if roleRestriction == "" {
+ return nil, fmt.Errorf(
+ "baton-snowflake: service user %q has no default role; assign a role and set it as "+
+ "the user's default role before issuing a programmatic access token",
+ input.IdentityID.Resource,
+ )
+ }
+ granted, err := o.client.RoleGrantedToUser(ctx, input.IdentityID.Resource, roleRestriction)
+ switch {
+ case err == nil && !granted:
+ return nil, fmt.Errorf(
+ "baton-snowflake: service user %q default role %q is not granted to the user; "+
+ "grant it before issuing a programmatic access token",
+ input.IdentityID.Resource, roleRestriction,
+ )
+ case snowflake.IsInsufficientPrivileges(err):
+ // SHOW GRANTS TO USER needs privileges that creating the token does not.
+ // This check only turns a Snowflake rejection into a better message, so a
+ // role that cannot run it must still be allowed to issue.
+ ctxzap.Extract(ctx).Warn("baton-snowflake: skipping default-role check: insufficient privileges",
+ zap.String("username", input.IdentityID.Resource))
+ case err != nil:
+ return nil, fmt.Errorf("baton-snowflake: verify service user's default role: %w", err)
+ }
+ }
+
+ // Snowflake derives the expiry from its own clock at ALTER USER time, so sample
+ // as late as possible: every round-trip between here and the statement pushes the
+ // real expiry further past the one computed below. Floor rather than round up so
+ // the provider's expiration is never later than the caller's deadline.
+ now := time.Now().UTC()
+ days := programmaticAccessTokenDefaultDays
+ if input.ExpiresAt != nil {
+ days = int(input.ExpiresAt.AsTime().Sub(now) / (24 * time.Hour))
+ if days < 1 {
+ return nil, fmt.Errorf("baton-snowflake: requested expiry leaves less than Snowflake's one-day minimum")
+ }
+ }
+ expiresAt := now.AddDate(0, 0, days)
+
+ plaintext, err := o.client.CreateProgrammaticAccessToken(ctx, input.IdentityID.Resource, tokenName, roleRestriction, days)
+ if err != nil {
+ return nil, fmt.Errorf("baton-snowflake: create programmatic access token: %w", err)
+ }
+
+ // The token now exists in Snowflake. Every failure below discards the plaintext
+ // without recording a secret resource, so without this the credential is left
+ // live and unreferenced: the SDK does not retry issuance, and nothing else
+ // holds a handle to revoke it.
+ issued := false
+ defer func() {
+ if issued {
+ return
+ }
+ // Detached from ctx so cleanup still runs when the caller's context is done.
+ if rmErr := o.client.RemoveProgrammaticAccessToken(
+ context.WithoutCancel(ctx), input.IdentityID.Resource, tokenName,
+ ); rmErr != nil {
+ ctxzap.Extract(ctx).Error("baton-snowflake: failed to remove orphaned programmatic access token",
+ zap.String("username", input.IdentityID.Resource),
+ zap.String("token_name", tokenName),
+ zap.Error(rmErr))
+ }
+ }()
+
+ issuedTokens, err := o.client.ListProgrammaticAccessTokens(ctx, input.IdentityID.Resource)
+ switch {
+ case err == nil:
+ found := false
+ for _, token := range issuedTokens {
+ if token.Name == tokenName {
+ expiresAt = token.ExpiresAt
+ found = true
+ break
+ }
+ }
+ if !found {
+ return nil, fmt.Errorf("baton-snowflake: created programmatic access token was not returned by Snowflake")
+ }
+ if input.ExpiresAt != nil && expiresAt.After(input.ExpiresAt.AsTime()) {
+ return nil, fmt.Errorf("baton-snowflake: provider expiry exceeds requested expiry")
+ }
+ case snowflake.IsInsufficientPrivileges(err):
+ // Reading the token back needs ownership or MONITOR on the target user, which
+ // creating it does not. Destroying a good credential because the connector's
+ // role cannot see it would make issuance impossible for such a tenant. The
+ // locally computed expiry is never later than Snowflake's, so reporting it
+ // errs towards early rotation rather than towards a credential that outlives
+ // what C1 believes.
+ ctxzap.Extract(ctx).Warn("baton-snowflake: reporting a locally computed token expiry: insufficient privileges to read it back",
+ zap.String("username", input.IdentityID.Resource),
+ zap.Time("estimated_expires_at", expiresAt))
+ default:
+ return nil, fmt.Errorf("baton-snowflake: read created programmatic access token expiry: %w", err)
+ }
+
+ secret, err := newProgrammaticAccessTokenResource(input.IdentityID, tokenName, expiresAt)
+ if err != nil {
+ return nil, err
+ }
+
+ issued = true
+ return &connectorbuilder.CredentialIssueOutput{
+ Secret: secret,
+ PlaintextData: []*v2.PlaintextData{
+ v2.PlaintextData_builder{Name: "token", Bytes: []byte(plaintext)}.Build(),
+ },
+ ResourceMode: v2.CredentialResourceMode_CREDENTIAL_RESOURCE_MODE_DISCOVERABLE,
+ }, nil
+}
+
+func isServiceUserType(userType string) bool {
+ switch strings.ToUpper(strings.TrimSpace(userType)) {
+ case userTypeService, userTypeServiceAgent, userTypeLegacyService:
+ return true
+ default:
+ return false
+ }
+}
+
func (o *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType {
return userResourceType
}
@@ -67,7 +239,14 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v
rs.WithResourceStatus(getUserStatus(user), getUserDetailedStatus(user)),
}
if syncSecrets {
- opts = append(opts, rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id}))
+ // The syncer only calls a child type's List with a parent when the parent
+ // carries this annotation. Without the token entry an issued programmatic
+ // access token is never discovered by a sync, which contradicts the
+ // DISCOVERABLE resource mode the issuer advertises.
+ opts = append(opts,
+ rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: rsaPublicKeyResourceType.Id}),
+ rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: programmaticAccessTokenResourceType.Id}),
+ )
}
if nhiType, nhiDetail, isNHI := classifyUserNHI(user.Type); isNHI {
opts = append(opts, rs.WithNHIType(nhiType, nhiDetail))
@@ -89,6 +268,7 @@ func userResource(_ context.Context, user *snowflake.User, syncSecrets bool) (*v
}
// https://docs.snowflake.com/en/sql-reference/sql/create-user#label-user-type-property
+//
// TYPE = { PERSON | SERVICE | SERVICE_AGENT | LEGACY_SERVICE }
const (
userTypeService = "SERVICE"
@@ -101,8 +281,7 @@ const (
)
func getUserAccountType(user *snowflake.User) v2.UserTrait_AccountType {
- switch strings.ToUpper(strings.TrimSpace(user.Type)) {
- case userTypeService, userTypeServiceAgent, userTypeLegacyService:
+ if isServiceUserType(user.Type) {
return v2.UserTrait_ACCOUNT_TYPE_SERVICE
}
return v2.UserTrait_ACCOUNT_TYPE_HUMAN
diff --git a/pkg/snowflake/helper.go b/pkg/snowflake/helper.go
index a8f40159..7e427ca4 100644
--- a/pkg/snowflake/helper.go
+++ b/pkg/snowflake/helper.go
@@ -32,6 +32,18 @@ func isAccessControlDenial(resp *http.Response, apiErr *SnowflakeError) bool {
apiErr.Code == sqlAccessControlErrorCode
}
+// NormalizeNullValue maps Snowflake's textual rendering of a NULL cell to an empty string.
+// The SQL API returns every column as text, so an unset property such as DEFAULT_ROLE arrives
+// as the literal "null" rather than as "". Callers testing a string column for absence must go
+// through this, or the absence test silently never matches.
+func NormalizeNullValue(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == rowNull {
+ return ""
+ }
+ return trimmed
+}
+
// IsInsufficientPrivileges reports whether err is a Snowflake access-control denial that the
// connector may skip (HTTP 422 with Snowflake code 003001, joined as ErrInsufficientPrivileges).
//
diff --git a/pkg/snowflake/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go
new file mode 100644
index 00000000..ace5adc3
--- /dev/null
+++ b/pkg/snowflake/programmatic_access_tokens.go
@@ -0,0 +1,163 @@
+package snowflake
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/conductorone/baton-sdk/pkg/uhttp"
+ "google.golang.org/grpc/codes"
+)
+
+type ProgrammaticAccessToken struct {
+ Name string
+ ExpiresAt time.Time
+}
+
+func (c *Client) RoleGrantedToUser(ctx context.Context, userName, roleName string) (bool, error) {
+ result, err := c.executeStatement(ctx, fmt.Sprintf("SHOW GRANTS TO USER %s;", quoteIdentifier(userName)))
+ if err != nil {
+ return false, err
+ }
+ for _, row := range result.Data {
+ grantedOn, err := result.ResultSetMetadata.GetStringValueFromRow(row, "granted_on")
+ if err != nil {
+ return false, fmt.Errorf("snowflake: read user role grant type: %w", err)
+ }
+ name, err := result.ResultSetMetadata.GetStringValueFromRow(row, "name")
+ if err != nil {
+ return false, fmt.Errorf("snowflake: read user role grant name: %w", err)
+ }
+ // SHOW GRANTS wraps a mixed-case or spaced identifier in double quotes, while
+ // DESCRIBE USER reports DEFAULT_ROLE bare, so comparing the two raw strings
+ // reports a granted role as ungranted and blocks issuance entirely.
+ if strings.EqualFold(grantedOn, "ROLE") &&
+ strings.EqualFold(unquoteSnowflakeIdentifier(name), unquoteSnowflakeIdentifier(roleName)) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (c *Client) ListProgrammaticAccessTokens(ctx context.Context, userName string) ([]ProgrammaticAccessToken, error) {
+ result, err := c.executeStatement(ctx, fmt.Sprintf("SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER %s;", quoteIdentifier(userName)))
+ if err != nil {
+ return nil, err
+ }
+ tokens := make([]ProgrammaticAccessToken, 0, len(result.Data))
+ for _, row := range result.Data {
+ name, err := result.ResultSetMetadata.GetStringValueFromRow(row, "name")
+ if err != nil {
+ return nil, fmt.Errorf("snowflake: read programmatic access token name: %w", err)
+ }
+ expiresAt, err := result.ResultSetMetadata.GetTimeValueFromRow(row, "expires_at")
+ if err != nil {
+ return nil, fmt.Errorf("snowflake: read programmatic access token expiry: %w", err)
+ }
+ tokens = append(tokens, ProgrammaticAccessToken{Name: name, ExpiresAt: expiresAt})
+ }
+ return tokens, nil
+}
+
+// CreateProgrammaticAccessToken creates a token and returns the secret supplied
+// by Snowflake in the one response where it is available. It never logs it.
+func (c *Client) CreateProgrammaticAccessToken(ctx context.Context, userName, tokenName, roleRestriction string, daysToExpiry int) (string, error) {
+ if daysToExpiry < 1 {
+ return "", fmt.Errorf("snowflake: days to expiry must be at least one")
+ }
+ roleClause := ""
+ if roleRestriction != "" {
+ roleClause = fmt.Sprintf(" ROLE_RESTRICTION = %s", quoteIdentifier(roleRestriction))
+ }
+ statement := fmt.Sprintf(
+ "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s%s DAYS_TO_EXPIRY = %d;",
+ quoteIdentifier(userName), quoteIdentifier(tokenName), roleClause, daysToExpiry,
+ )
+ result, err := c.executeStatementAsUserAdmin(ctx, statement)
+ if err != nil {
+ return "", err
+ }
+ if len(result.Data) != 1 {
+ return "", fmt.Errorf("snowflake: programmatic access token response did not return exactly one row")
+ }
+ // By column name, not position: a reordered or inserted column would otherwise
+ // return some other field as the credential instead of failing.
+ secret, err := result.ResultSetMetadata.GetStringValueFromRow(result.Data[0], "token_secret")
+ if err != nil {
+ return "", fmt.Errorf("snowflake: read programmatic access token secret: %w", err)
+ }
+ if secret == "" {
+ return "", fmt.Errorf("snowflake: programmatic access token response did not include token_secret")
+ }
+ return secret, nil
+}
+
+func (c *Client) RemoveProgrammaticAccessToken(ctx context.Context, userName, tokenName string) error {
+ statement := fmt.Sprintf(
+ "ALTER USER IF EXISTS %s REMOVE PROGRAMMATIC ACCESS TOKEN IF EXISTS %s;",
+ quoteIdentifier(userName), quoteIdentifier(tokenName),
+ )
+ _, err := c.executeStatementAsUserAdmin(ctx, statement)
+ return err
+}
+
+func (c *Client) executeStatement(ctx context.Context, statement string) (*StatementsApiResponseBase, error) {
+ return c.executeStatementWithRole(ctx, statement, "")
+}
+
+// executeStatementAsUserAdmin runs a statement that mutates another user. The session's
+// default role is not guaranteed to hold ALTER USER on other users, which is why
+// SetUserDisabled, CreateUserREST and DeleteUserREST all force UserAdminRole too.
+func (c *Client) executeStatementAsUserAdmin(ctx context.Context, statement string) (*StatementsApiResponseBase, error) {
+ return c.executeStatementWithRole(ctx, statement, UserAdminRole)
+}
+
+func (c *Client) executeStatementWithRole(ctx context.Context, statement, role string) (*StatementsApiResponseBase, error) {
+ req, err := c.PostStatementRequestWithRole(ctx, []string{statement}, role)
+ if err != nil {
+ return nil, err
+ }
+ var result StatementsApiResponseBase
+ var apiErr SnowflakeError
+ resp, err := c.Do(req, uhttp.WithJSONResponse(&result), uhttp.WithErrorResponse(&apiErr))
+ defer closeResponseBody(resp)
+ if err != nil {
+ return nil, classifyStatementError(resp, &apiErr, err)
+ }
+ if result.StatementHandle == "" {
+ return &result, nil
+ }
+ req, err = c.GetStatementResponse(ctx, result.StatementHandle)
+ if err != nil {
+ return nil, err
+ }
+ resp, err = c.Do(req, uhttp.WithJSONResponse(&result), uhttp.WithErrorResponse(&apiErr))
+ defer closeResponseBody(resp)
+ if err != nil {
+ // Same classification as the POST leg: Snowflake reports an access-control
+ // denial on whichever leg surfaces the statement's outcome, and a statement
+ // that went async reports it here.
+ return nil, classifyStatementError(resp, &apiErr, err)
+ }
+ return &result, nil
+}
+
+// classifyStatementError joins ErrInsufficientPrivileges when Snowflake refused the statement
+// because the connector's role may not observe the object, so callers can skip it with
+// IsInsufficientPrivileges instead of failing the whole sync. Every other error is unchanged.
+func classifyStatementError(resp *http.Response, apiErr *SnowflakeError, err error) error {
+ if isAccessControlDenial(resp, apiErr) {
+ return uhttp.WrapErrors(
+ codes.PermissionDenied,
+ "baton-snowflake: insufficient privileges to run statement",
+ ErrInsufficientPrivileges, err,
+ )
+ }
+ return dedupeAPIError(err)
+}
+
+func quoteIdentifier(identifier string) string {
+ return "\"" + escapeDoubleQuotedIdentifier(identifier) + "\""
+}
diff --git a/pkg/snowflake/programmatic_access_tokens_test.go b/pkg/snowflake/programmatic_access_tokens_test.go
new file mode 100644
index 00000000..2368d2d3
--- /dev/null
+++ b/pkg/snowflake/programmatic_access_tokens_test.go
@@ -0,0 +1,240 @@
+package snowflake
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// The issuance statement is built from one format string with an optional role
+// clause interpolated into it. These pin both shapes: the clause cannot simply be
+// appended, because the statement ends with a semicolon and Snowflake rejects
+// anything after the terminator with a SQL compilation error.
+func TestCreateProgrammaticAccessTokenStatementShape(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ roleRestriction string
+ want string
+ }{
+ {
+ name: "without a role restriction",
+ roleRestriction: "",
+ want: `ALTER USER "svc" ADD PROGRAMMATIC ACCESS TOKEN "c1-request-1" DAYS_TO_EXPIRY = 7;`,
+ },
+ {
+ name: "with a role restriction",
+ roleRestriction: "svc_role",
+ want: `ALTER USER "svc" ADD PROGRAMMATIC ACCESS TOKEN "c1-request-1" ROLE_RESTRICTION = "svc_role" DAYS_TO_EXPIRY = 7;`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ recorder := &statementRecorder{}
+ server := newTokenCreateServer(t, recorder)
+ defer server.Close()
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ secret, err := client.CreateProgrammaticAccessToken(
+ context.Background(), "svc", "c1-request-1", tc.roleRestriction, 7,
+ )
+ require.NoError(t, err)
+ require.Equal(t, "the-secret", secret)
+ statements, _ := recorder.snapshot()
+ require.Equal(t, []string{tc.want}, statements)
+ })
+ }
+}
+
+func TestCreateProgrammaticAccessTokenRejectsNonPositiveExpiry(t *testing.T) {
+ client, err := New("https://example.snowflakecomputing.com", JWTConfig{}, http.DefaultClient)
+ require.NoError(t, err)
+
+ _, err = client.CreateProgrammaticAccessToken(context.Background(), "svc", "c1-request-1", "", 0)
+ require.ErrorContains(t, err, "days to expiry must be at least one")
+}
+
+// statementRecorder captures what each request carried. The handler runs on the
+// server's goroutine while the test reads from its own, so the mutex is load-bearing
+// under -race, not decoration.
+type statementRecorder struct {
+ mu sync.Mutex
+ statements []string
+ roles []string
+}
+
+func (r *statementRecorder) record(statement, role string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.statements = append(r.statements, statement)
+ r.roles = append(r.roles, role)
+}
+
+func (r *statementRecorder) snapshot() ([]string, []string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]string(nil), r.statements...), append([]string(nil), r.roles...)
+}
+
+// newTokenCreateServer records each statement it is sent and answers with the column
+// shape a live Snowflake ADD PROGRAMMATIC ACCESS TOKEN returns: [token_name, token_secret].
+func newTokenCreateServer(t *testing.T, recorder *statementRecorder) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Errorf("read request body: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ var request StatementsApiRequestBody
+ if err := json.Unmarshal(body, &request); err != nil {
+ t.Errorf("decode request: %v", err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ recorder.record(request.Statement, request.Role)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{
+ "numRows": 1,
+ "rowType": []map[string]any{
+ {"name": "token_name", "type": "text"},
+ {"name": "token_secret", "type": "text"},
+ },
+ },
+ "data": [][]string{{"c1-request-1", "the-secret"}},
+ })
+ }))
+}
+
+// A statement that goes async reports its outcome on the follow-up GET rather than
+// on the POST. Both legs must classify an access-control denial the same way, or a
+// denial that arrives asynchronously is indistinguishable from a real failure and
+// aborts the sync instead of skipping the object.
+func TestExecuteStatementClassifiesDenialOnEitherLeg(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ denyOnPost bool
+ }{
+ {name: "denied on the POST leg", denyOnPost: true},
+ {name: "denied on the follow-up GET leg", denyOnPost: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ deny := tc.denyOnPost == (r.Method == http.MethodPost)
+ w.Header().Set("Content-Type", "application/json")
+ if deny {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "code": "003001",
+ "message": "SQL access control error: Insufficient privileges to operate on user",
+ })
+ return
+ }
+ // Not the denying leg: hand back a handle so the client goes async.
+ _ = json.NewEncoder(w).Encode(map[string]any{"statementHandle": "handle-1"})
+ }))
+ defer server.Close()
+
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ _, err = client.ListProgrammaticAccessTokens(context.Background(), "svc")
+ require.Error(t, err)
+ require.True(t, IsInsufficientPrivileges(err),
+ "denial should be classified as skippable, got %v", err)
+ })
+ }
+}
+
+// ALTER USER mutates another user, and the session's default role is not guaranteed to
+// hold ALTER USER on other users. SetUserDisabled, CreateUserREST and DeleteUserREST all
+// force USERADMIN for the same reason; these statements must too.
+func TestTokenMutationsRunAsUserAdmin(t *testing.T) {
+ t.Parallel()
+ t.Run("create", func(t *testing.T) {
+ t.Parallel()
+ recorder := &statementRecorder{}
+ server := newTokenCreateServer(t, recorder)
+ defer server.Close()
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ _, err = client.CreateProgrammaticAccessToken(context.Background(), "svc", "c1-request-1", "", 7)
+ require.NoError(t, err)
+ _, roles := recorder.snapshot()
+ require.Equal(t, []string{UserAdminRole}, roles)
+ })
+
+ t.Run("remove", func(t *testing.T) {
+ t.Parallel()
+ recorder := &statementRecorder{}
+ server := newTokenCreateServer(t, recorder)
+ defer server.Close()
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ require.NoError(t, client.RemoveProgrammaticAccessToken(context.Background(), "svc", "c1-request-1"))
+ _, roles := recorder.snapshot()
+ require.Equal(t, []string{UserAdminRole}, roles)
+ })
+
+ t.Run("read-only statements do not force a role", func(t *testing.T) {
+ t.Parallel()
+ recorder := &statementRecorder{}
+ server := newTokenCreateServer(t, recorder)
+ defer server.Close()
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ _, _ = client.ListProgrammaticAccessTokens(context.Background(), "svc")
+ _, roles := recorder.snapshot()
+ require.Equal(t, []string{""}, roles, "reads should run as the session's default role")
+ })
+}
+
+// SHOW GRANTS wraps a mixed-case or spaced identifier in double quotes; DESCRIBE USER
+// reports DEFAULT_ROLE bare. Comparing the two raw strings reports a granted role as
+// ungranted, which blocks issuance for that user entirely.
+func TestRoleGrantedToUserMatchesQuotedIdentifiers(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ showName string
+ lookFor string
+ want bool
+ }{
+ {name: "bare name", showName: "SVC_ROLE", lookFor: "SVC_ROLE", want: true},
+ {name: "quoted mixed case", showName: `"Mixed Case Role"`, lookFor: "Mixed Case Role", want: true},
+ {name: "case differs", showName: "SVC_ROLE", lookFor: "svc_role", want: true},
+ {name: "genuinely absent", showName: "SVC_ROLE", lookFor: "OTHER_ROLE", want: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "resultSetMetadata": map[string]any{
+ "numRows": 1,
+ "rowType": []map[string]any{
+ {"name": "granted_on", "type": "text"},
+ {"name": "name", "type": "text"},
+ },
+ },
+ "data": [][]string{{"ROLE", tc.showName}},
+ })
+ }))
+ defer server.Close()
+ client, err := New(server.URL, JWTConfig{}, server.Client())
+ require.NoError(t, err)
+
+ got, err := client.RoleGrantedToUser(context.Background(), "svc", tc.lookFor)
+ require.NoError(t, err)
+ require.Equal(t, tc.want, got)
+ })
+ }
+}