From 4b1ac28833ed283d79736f1a1b691004228e36ed Mon Sep 17 00:00:00 2001 From: Santhosh Kumar Bala Krishnan Date: Wed, 19 Aug 2026 11:52:02 +0000 Subject: [PATCH 1/9] Add Snowflake credential issuance Co-authored-by: c1-squire-dev[bot] --- pkg/connector/connector.go | 7 +- pkg/connector/programmatic_access_tokens.go | 99 +++++++++++++++++++ .../programmatic_access_tokens_test.go | 63 ++++++++++++ pkg/connector/resource_types.go | 6 ++ pkg/connector/users.go | 93 +++++++++++++++++ pkg/snowflake/programmatic_access_tokens.go | 94 ++++++++++++++++++ 6 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/programmatic_access_tokens.go create mode 100644 pkg/connector/programmatic_access_tokens_test.go create mode 100644 pkg/snowflake/programmatic_access_tokens.go 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/programmatic_access_tokens.go b/pkg/connector/programmatic_access_tokens.go new file mode 100644 index 00000000..73433c4b --- /dev/null +++ b/pkg/connector/programmatic_access_tokens.go @@ -0,0 +1,99 @@ +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" +) + +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 { + return nil, nil, 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..6d461066 --- /dev/null +++ b/pkg/connector/programmatic_access_tokens_test.go @@ -0,0 +1,63 @@ +package connector + +import ( + "context" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" +) + +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()) + } +} 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..6a3bc108 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,97 @@ 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 +} + +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") + } + + // Snowflake accepts a whole number of days. Floor rather than round up so the + // provider's actual expiration is never later than the caller's deadline. + now := time.Now().UTC() + days := programmaticAccessTokenDefaultDays + expiresAt := now.AddDate(0, 0, days) + if input.ExpiresAt != nil { + remaining := input.ExpiresAt.AsTime().Sub(now) + days = int(remaining / (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) + } + + tokenName := "c1-" + input.RequestID + plaintext, err := o.client.CreateProgrammaticAccessToken(ctx, input.IdentityID.Resource, tokenName, days) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: create programmatic access token: %w", err) + } + issuedTokens, err := o.client.ListProgrammaticAccessTokens(ctx, input.IdentityID.Resource) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: read created programmatic access token expiry: %w", err) + } + 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") + } + + secret, err := newProgrammaticAccessTokenResource(input.IdentityID, tokenName, expiresAt) + if err != nil { + return nil, err + } + + 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 (o *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return userResourceType } @@ -89,6 +181,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" diff --git a/pkg/snowflake/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go new file mode 100644 index 00000000..dfeb0cd9 --- /dev/null +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -0,0 +1,94 @@ +package snowflake + +import ( + "context" + "fmt" + "time" + + "github.com/conductorone/baton-sdk/pkg/uhttp" +) + +type ProgrammaticAccessToken struct { + Name string + ExpiresAt time.Time +} + +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 string, daysToExpiry int) (string, error) { + if daysToExpiry < 1 { + return "", fmt.Errorf("snowflake: days to expiry must be at least one") + } + statement := fmt.Sprintf( + "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s DAYS_TO_EXPIRY = %d;", + quoteIdentifier(userName), quoteIdentifier(tokenName), daysToExpiry, + ) + result, err := c.executeStatement(ctx, statement) + if err != nil { + return "", err + } + if len(result.Data) != 1 || len(result.Data[0]) < 2 || result.Data[0][1] == "" { + return "", fmt.Errorf("snowflake: programmatic access token response did not include token_secret") + } + return result.Data[0][1], 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.executeStatement(ctx, statement) + return err +} + +func (c *Client) executeStatement(ctx context.Context, statement string) (*StatementsApiResponseBase, error) { + req, err := c.PostStatementRequest(ctx, []string{statement}) + 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, dedupeAPIError(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 { + return nil, dedupeAPIError(err) + } + return &result, nil +} + +func quoteIdentifier(identifier string) string { + return "\"" + escapeDoubleQuotedIdentifier(identifier) + "\"" +} From b368edce3a0896fe0c0eb81a5723c6672cac7726 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:29:08 +0000 Subject: [PATCH 2/9] Fix service user PAT role restriction Co-authored-by: c1-squire-dev[bot] --- .../programmatic_access_tokens_test.go | 120 ++++++++++++++++++ pkg/connector/users.go | 34 ++++- pkg/snowflake/programmatic_access_tokens.go | 30 ++++- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/pkg/connector/programmatic_access_tokens_test.go b/pkg/connector/programmatic_access_tokens_test.go index 6d461066..3fbfabba 100644 --- a/pkg/connector/programmatic_access_tokens_test.go +++ b/pkg/connector/programmatic_access_tokens_test.go @@ -2,12 +2,132 @@ package connector import ( "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" "testing" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-snowflake/pkg/snowflake" ) +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) + } +} + +func newCredentialIssueMockServer(t *testing.T, userType, defaultRole string, roleGranted bool, statements *[]string) *httptest.Server { + t.Helper() + 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 + } + *statements = append(*statements, request.Statement) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasPrefix(request.Statement, "DESCRIBE USER"): + _ = 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", defaultRole}, {"TYPE", userType}, + {"HAS_MFA", "false"}, {"COMMENT", ""}, + }, + }) + case strings.HasPrefix(request.Statement, "SHOW GRANTS TO USER"): + data := [][]string{} + if roleGranted { + data = append(data, []string{"ROLE", 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"): + _ = json.NewEncoder(w).Encode(map[string]any{"data": [][]string{{"C1_REQUEST_1", "redacted"}}}) + case strings.HasPrefix(request.Statement, "SHOW USER PROGRAMMATIC ACCESS TOKENS"): + _ = 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{{"c1-request-1", "1893456000"}}, + }) + 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 { diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 6a3bc108..3ea1fc45 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -34,6 +34,8 @@ type credentialUserBuilder struct { *userBuilder } +var _ connectorbuilder.CredentialIssuerV2 = (*credentialUserBuilder)(nil) + const ( programmaticAccessTokenMinLifetime = 24 * time.Hour programmaticAccessTokenMaxLifetime = 365 * 24 * time.Hour @@ -81,7 +83,25 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild } tokenName := "c1-" + input.RequestID - plaintext, err := o.client.CreateProgrammaticAccessToken(ctx, input.IdentityID.Resource, tokenName, days) + 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) { + roleRestriction = strings.TrimSpace(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) + if err != nil { + return nil, fmt.Errorf("baton-snowflake: verify service user's default role: %w", err) + } + if !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) + } + } + 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) } @@ -118,6 +138,15 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild }, 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 } @@ -194,8 +223,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/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go index dfeb0cd9..d2b95490 100644 --- a/pkg/snowflake/programmatic_access_tokens.go +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -3,6 +3,7 @@ package snowflake import ( "context" "fmt" + "strings" "time" "github.com/conductorone/baton-sdk/pkg/uhttp" @@ -13,6 +14,27 @@ type ProgrammaticAccessToken struct { 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) + } + if strings.EqualFold(grantedOn, "ROLE") && name == 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 { @@ -35,7 +57,7 @@ func (c *Client) ListProgrammaticAccessTokens(ctx context.Context, userName stri // 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 string, daysToExpiry int) (string, error) { +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") } @@ -43,6 +65,12 @@ func (c *Client) CreateProgrammaticAccessToken(ctx context.Context, userName, to "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s DAYS_TO_EXPIRY = %d;", quoteIdentifier(userName), quoteIdentifier(tokenName), daysToExpiry, ) + if roleRestriction != "" { + statement = fmt.Sprintf( + "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s ROLE_RESTRICTION = %s DAYS_TO_EXPIRY = %d;", + quoteIdentifier(userName), quoteIdentifier(tokenName), quoteIdentifier(roleRestriction), daysToExpiry, + ) + } result, err := c.executeStatement(ctx, statement) if err != nil { return "", err From d1a71db2c012f7b6d4f941786dcf4cf7443bf9ee Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:31:39 +0000 Subject: [PATCH 3/9] fix(pat): make issued tokens discoverable and stop orphaning them Programmatic access tokens could never be discovered by a sync. The syncer walks a child resource type once per parent only when the parent carries a ChildResourceType annotation, and the user resource annotated only rsa_public_key. The sole remaining call is the top-level one with a nil parent, which the token builder returns nothing for, so no token ever appeared in inventory no matter how many existed -- while the issuer advertised CREDENTIAL_RESOURCE_MODE_DISCOVERABLE. That also meant an issued token could not be found later in order to revoke it. Verified against a live Snowflake account: before, a sync over three users walked programmatic_access_token exactly once and reported zero with a token demonstrably present; after, it walks once per user and finds it. The full issue -> sync -> DeleteResourceV2 -> sync lifecycle now passes. Also in this change: - Remove the token when any step after creation fails. The plaintext is discarded and no secret resource is recorded on those paths, and the SDK does not retry issuance, so the credential was left live with nothing holding a handle to revoke it. Cleanup is detached from the request context so it still runs when the caller's context is done. - Fix the unreachable "no default role" check. The SQL API returns every column as text, so an unset DEFAULT_ROLE arrives as the literal string "null" rather than empty. The absence test never matched, and callers were sent to the other branch and told to grant a role named "null". Normalizing goes through the existing rowNull constant rather than a second spelling. - Read the issued token secret by column name instead of by position, so a reordered or inserted column fails loudly rather than silently returning another field as the credential. Confirmed against a live response that the column is token_secret. - Skip a user whose tokens the connector's role may not read (422/003001) instead of failing the whole sync, matching the existing handling in pkg/connector/rsa.go. - Regenerate baton_capabilities.json and document token issuance, including Snowflake's requirement that the user have a network policy attached before it will mint a token. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- baton_capabilities.json | 41 ++++++- docs/connector.mdx | 15 +++ pkg/connector/integrations.go | 2 +- pkg/connector/profile_keys.go | 1 + pkg/connector/programmatic_access_tokens.go | 12 +- .../programmatic_access_tokens_test.go | 105 +++++++++++++++++- pkg/connector/users.go | 47 +++++++- pkg/snowflake/helper.go | 12 ++ pkg/snowflake/programmatic_access_tokens.go | 31 +++++- 9 files changed, 251 insertions(+), 15 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index d9f01eed..75ff472b 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,16 +152,32 @@ "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], - "permissions": {} + "permissions": {}, + "credentialIssue": { + "options": [ + { + "option": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN", + "expiry": { + "min": "86400s", + "max": "31536000s" + }, + "resourceMode": "CREDENTIAL_RESOURCE_MODE_DISCOVERABLE", + "secretResourceTypeId": "programmatic_access_token" + } + ], + "preferredOption": "CAPABILITY_DETAIL_CREDENTIAL_OPTION_TOKEN" + } } ], "connectorCapabilities": [ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_CREDENTIAL_ISSUE" ], "credentialDetails": { "capabilityAccountProvisioning": { diff --git a/docs/connector.mdx b/docs/connector.mdx index 93b87df8..f19e6c19 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 actually granted to the user first. If the user has no default role, or the default role is not granted, issuance fails before any token is created. + + **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/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 index 73433c4b..371f079f 100644 --- a/pkg/connector/programmatic_access_tokens.go +++ b/pkg/connector/programmatic_access_tokens.go @@ -11,6 +11,8 @@ import ( "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 { @@ -31,7 +33,15 @@ func (o *programmaticAccessTokenBuilder) List(ctx context.Context, parentID *v2. } tokens, err := o.client.ListProgrammaticAccessTokens(ctx, parentID.GetResource()) if err != nil { - return nil, nil, err + // 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 { diff --git a/pkg/connector/programmatic_access_tokens_test.go b/pkg/connector/programmatic_access_tokens_test.go index 3fbfabba..ececc946 100644 --- a/pkg/connector/programmatic_access_tokens_test.go +++ b/pkg/connector/programmatic_access_tokens_test.go @@ -57,6 +57,12 @@ func TestCredentialUserBuilderIssueServiceUserWithUnassignedDefaultRoleFailsBefo } func newCredentialIssueMockServer(t *testing.T, userType, defaultRole string, roleGranted bool, statements *[]string) *httptest.Server { + return newCredentialIssueMockServerWithShowName(t, userType, defaultRole, roleGranted, "c1-request-1", statements) +} + +// 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. +func newCredentialIssueMockServerWithShowName(t *testing.T, userType, defaultRole string, roleGranted bool, showTokenName string, statements *[]string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -103,15 +109,28 @@ func newCredentialIssueMockServer(t *testing.T, userType, defaultRole string, ro "data": data, }) case strings.Contains(request.Statement, "ADD PROGRAMMATIC ACCESS TOKEN"): - _ = json.NewEncoder(w).Encode(map[string]any{"data": [][]string{{"C1_REQUEST_1", "redacted"}}}) + // 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"): _ = 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{{"c1-request-1", "1893456000"}}, + "data": [][]string{{showTokenName, "1893456000"}}, }) + 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) @@ -174,10 +193,90 @@ func TestIssueCapabilityDetails(t *testing.T) { 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 { + 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) + } + } +} diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 3ea1fc45..42113da6 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -89,22 +89,53 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild } roleRestriction := "" if isServiceUserType(user.Type) { - roleRestriction = strings.TrimSpace(user.DefaultRole) + // 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) + 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) if err != nil { return nil, fmt.Errorf("baton-snowflake: verify service user's default role: %w", err) } if !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) + 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, + ) } } 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) if err != nil { return nil, fmt.Errorf("baton-snowflake: read created programmatic access token expiry: %w", err) @@ -129,6 +160,7 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild return nil, err } + issued = true return &connectorbuilder.CredentialIssueOutput{ Secret: secret, PlaintextData: []*v2.PlaintextData{ @@ -188,7 +220,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)) 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 index d2b95490..5bb9e5df 100644 --- a/pkg/snowflake/programmatic_access_tokens.go +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -3,10 +3,12 @@ package snowflake import ( "context" "fmt" + "net/http" "strings" "time" "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/codes" ) type ProgrammaticAccessToken struct { @@ -75,10 +77,19 @@ func (c *Client) CreateProgrammaticAccessToken(ctx context.Context, userName, to if err != nil { return "", err } - if len(result.Data) != 1 || len(result.Data[0]) < 2 || result.Data[0][1] == "" { + 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 result.Data[0][1], nil + return secret, nil } func (c *Client) RemoveProgrammaticAccessToken(ctx context.Context, userName, tokenName string) error { @@ -100,7 +111,7 @@ func (c *Client) executeStatement(ctx context.Context, statement string) (*State resp, err := c.Do(req, uhttp.WithJSONResponse(&result), uhttp.WithErrorResponse(&apiErr)) defer closeResponseBody(resp) if err != nil { - return nil, dedupeAPIError(err) + return nil, classifyStatementError(resp, &apiErr, err) } if result.StatementHandle == "" { return &result, nil @@ -117,6 +128,20 @@ func (c *Client) executeStatement(ctx context.Context, statement string) (*State 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) + "\"" } From f288029be6cf5f46192019bdc5510a4ba6ebb1f2 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:44:21 +0000 Subject: [PATCH 4/9] refactor(pat): build the token statement once CreateProgrammaticAccessToken formatted the whole statement, then discarded it and formatted it again when a role restriction was present. Both the format string and its arguments were duplicated, so any future change to the statement had to be made in two places or silently apply to only one of them. Build the optional clause instead and interpolate it, which keeps a single source of truth for the statement shape. The clause cannot simply be appended to the finished statement: that string ends with a semicolon, so the appended text lands after the terminator and Snowflake rejects it with a SQL compilation error. Interpolating keeps ROLE_RESTRICTION in its documented position ahead of DAYS_TO_EXPIRY. Snowflake does accept the two clauses in either order, but relying on that would be relying on undocumented behaviour for no benefit. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- pkg/snowflake/programmatic_access_tokens.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/snowflake/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go index 5bb9e5df..4162c9ec 100644 --- a/pkg/snowflake/programmatic_access_tokens.go +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -63,16 +63,14 @@ func (c *Client) CreateProgrammaticAccessToken(ctx context.Context, userName, to if daysToExpiry < 1 { return "", fmt.Errorf("snowflake: days to expiry must be at least one") } - statement := fmt.Sprintf( - "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s DAYS_TO_EXPIRY = %d;", - quoteIdentifier(userName), quoteIdentifier(tokenName), daysToExpiry, - ) + roleClause := "" if roleRestriction != "" { - statement = fmt.Sprintf( - "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s ROLE_RESTRICTION = %s DAYS_TO_EXPIRY = %d;", - quoteIdentifier(userName), quoteIdentifier(tokenName), quoteIdentifier(roleRestriction), daysToExpiry, - ) + 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.executeStatement(ctx, statement) if err != nil { return "", err From 22b60585f7ce4a4daeba797c0336dbe03db46abc Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:45:35 +0000 Subject: [PATCH 5/9] test(pat): pin both issuance statement shapes The role-restricted statement was covered only indirectly, by asserting that a substring appeared somewhere in the statements a mock server received, and the unrestricted statement was not covered at all. The statement construction was just reworked, so pin both shapes to their exact SQL. These also guard the specific mistake the rework avoids: appending the role clause to the finished statement puts it after the terminating semicolon, which Snowflake rejects with a SQL compilation error. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- .../programmatic_access_tokens_test.go | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 pkg/snowflake/programmatic_access_tokens_test.go diff --git a/pkg/snowflake/programmatic_access_tokens_test.go b/pkg/snowflake/programmatic_access_tokens_test.go new file mode 100644 index 00000000..aa204e54 --- /dev/null +++ b/pkg/snowflake/programmatic_access_tokens_test.go @@ -0,0 +1,90 @@ +package snowflake + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "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) { + var statements []string + server := newTokenCreateServer(t, &statements) + 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) + 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") +} + +// 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, statements *[]string) *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 + } + *statements = append(*statements, request.Statement) + 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"}}, + }) + })) +} From a1d824ca267efa1cd850a2038fc5380eef31c87c Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:06:34 +0000 Subject: [PATCH 6/9] fix(pat): classify access-control denials on the async statement leg executeStatement classified a Snowflake access-control denial only on the POST leg. A statement that goes async reports its outcome on the follow-up GET instead, and that leg returned the raw error, so a 422/003001 arriving there was indistinguishable from a real failure and aborted the sync rather than skipping the object the connector's role cannot see. Both legs now go through classifyStatementError. The regression test drives a denial down each leg independently and fails on the GET case without this change. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- pkg/snowflake/programmatic_access_tokens.go | 5 ++- .../programmatic_access_tokens_test.go | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/snowflake/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go index 4162c9ec..f3295c22 100644 --- a/pkg/snowflake/programmatic_access_tokens.go +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -121,7 +121,10 @@ func (c *Client) executeStatement(ctx context.Context, statement string) (*State resp, err = c.Do(req, uhttp.WithJSONResponse(&result), uhttp.WithErrorResponse(&apiErr)) defer closeResponseBody(resp) if err != nil { - return nil, dedupeAPIError(err) + // 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 } diff --git a/pkg/snowflake/programmatic_access_tokens_test.go b/pkg/snowflake/programmatic_access_tokens_test.go index aa204e54..1c5fc232 100644 --- a/pkg/snowflake/programmatic_access_tokens_test.go +++ b/pkg/snowflake/programmatic_access_tokens_test.go @@ -88,3 +88,43 @@ func newTokenCreateServer(t *testing.T, statements *[]string) *httptest.Server { }) })) } + +// 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) + }) + } +} From ff1ec0c9fc45f43257a81f0a4e176f97f8cd4446 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:11:46 +0000 Subject: [PATCH 7/9] fix(pat): match quoted role identifiers and run ALTER USER as USERADMIN Two defects in the service-user issuance path, both confirmed against a live Snowflake account. Quoted identifiers. SHOW GRANTS TO USER wraps a mixed-case or spaced identifier in double quotes, while DESCRIBE USER reports DEFAULT_ROLE bare. Comparing the two raw strings reported a granted role as ungranted, so issuance failed with "default role %q is not granted to the user" for a role that was granted, and no user whose default role is mixed-case or spaced could be issued a token at all. Both sides now go through unquoteSnowflakeIdentifier and compare case-insensitively. Role on user mutations. ALTER USER ... ADD/REMOVE PROGRAMMATIC ACCESS TOKEN ran with no role, so it executed under the session's default role. SetUserDisabled, CreateUserREST and DeleteUserREST all force USERADMIN precisely because the session default is not guaranteed to hold ALTER USER on other users; these two statements now do the same. Reads are untouched and still run as the session role. Verified live: with the target's default role set to "Mixed Case Role", issuance failed before this change and the full issue -> delete -> verify-absent lifecycle passes after it. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- pkg/snowflake/programmatic_access_tokens.go | 23 ++++- .../programmatic_access_tokens_test.go | 85 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/pkg/snowflake/programmatic_access_tokens.go b/pkg/snowflake/programmatic_access_tokens.go index f3295c22..ace5adc3 100644 --- a/pkg/snowflake/programmatic_access_tokens.go +++ b/pkg/snowflake/programmatic_access_tokens.go @@ -30,7 +30,11 @@ func (c *Client) RoleGrantedToUser(ctx context.Context, userName, roleName strin if err != nil { return false, fmt.Errorf("snowflake: read user role grant name: %w", err) } - if strings.EqualFold(grantedOn, "ROLE") && name == roleName { + // 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 } } @@ -71,7 +75,7 @@ func (c *Client) CreateProgrammaticAccessToken(ctx context.Context, userName, to "ALTER USER %s ADD PROGRAMMATIC ACCESS TOKEN %s%s DAYS_TO_EXPIRY = %d;", quoteIdentifier(userName), quoteIdentifier(tokenName), roleClause, daysToExpiry, ) - result, err := c.executeStatement(ctx, statement) + result, err := c.executeStatementAsUserAdmin(ctx, statement) if err != nil { return "", err } @@ -95,12 +99,23 @@ func (c *Client) RemoveProgrammaticAccessToken(ctx context.Context, userName, to "ALTER USER IF EXISTS %s REMOVE PROGRAMMATIC ACCESS TOKEN IF EXISTS %s;", quoteIdentifier(userName), quoteIdentifier(tokenName), ) - _, err := c.executeStatement(ctx, statement) + _, err := c.executeStatementAsUserAdmin(ctx, statement) return err } func (c *Client) executeStatement(ctx context.Context, statement string) (*StatementsApiResponseBase, error) { - req, err := c.PostStatementRequest(ctx, []string{statement}) + 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 } diff --git a/pkg/snowflake/programmatic_access_tokens_test.go b/pkg/snowflake/programmatic_access_tokens_test.go index 1c5fc232..4cd737ee 100644 --- a/pkg/snowflake/programmatic_access_tokens_test.go +++ b/pkg/snowflake/programmatic_access_tokens_test.go @@ -59,6 +59,8 @@ func TestCreateProgrammaticAccessTokenRejectsNonPositiveExpiry(t *testing.T) { // 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]. +var lastRole string + func newTokenCreateServer(t *testing.T, statements *[]string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -75,6 +77,7 @@ func newTokenCreateServer(t *testing.T, statements *[]string) *httptest.Server { return } *statements = append(*statements, request.Statement) + lastRole = request.Role w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "resultSetMetadata": map[string]any{ @@ -128,3 +131,85 @@ func TestExecuteStatementClassifiesDenialOnEitherLeg(t *testing.T) { }) } } + +// 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.Run("create", func(t *testing.T) { + var statements []string + lastRole = "" + server := newTokenCreateServer(t, &statements) + 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) + require.Equal(t, UserAdminRole, lastRole) + }) + + t.Run("remove", func(t *testing.T) { + var statements []string + lastRole = "" + server := newTokenCreateServer(t, &statements) + 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")) + require.Equal(t, UserAdminRole, lastRole) + }) + + t.Run("read-only statements do not force a role", func(t *testing.T) { + var statements []string + lastRole = "sentinel" + server := newTokenCreateServer(t, &statements) + defer server.Close() + client, err := New(server.URL, JWTConfig{}, server.Client()) + require.NoError(t, err) + + _, _ = client.ListProgrammaticAccessTokens(context.Background(), "svc") + require.Empty(t, lastRole, "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) + }) + } +} From 8f6276ff6d6ecf66836755911e9d59451317fbda Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:00:50 +0000 Subject: [PATCH 8/9] fix(pat): sample the expiry clock late and survive read-back denials Issue() sampled time.Now() before DESCRIBE USER, SHOW GRANTS TO USER and the ALTER USER round-trip, but Snowflake derives a token's expiry from its own clock at ALTER USER time. The real expiry is therefore later than the computed one by however long the pre-flight took, so a request whose remaining time sits just above a whole number of days trips the "provider expiry exceeds requested" guard and the cleanup defer destroys a token that was fine. Sampling after the pre-flight leaves only the create round-trip inside the window and yields a shorter token instead of a failed issuance. The post-creation read-back and the default-role pre-check both ran as the session's default role while creation forces USERADMIN. SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER needs ownership or MONITOR on the target user and SHOW GRANTS TO USER needs its own privileges, neither of which creating a token requires. A role holding one and not the other created a good credential and immediately destroyed it, so issuance could never succeed for that tenant. Both now degrade on 422/003001: the role check is skipped and Snowflake is left to reject the statement itself, and the read-back falls back to the locally computed expiry, which is never later than the provider's. Replaces the package-level lastRole in the Snowflake tests with a mutex-guarded per-test recorder, which removes the order dependency between subtests and lets them run in parallel. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- .../programmatic_access_tokens_test.go | 168 +++++++++++++++++- pkg/connector/users.go | 84 +++++---- .../programmatic_access_tokens_test.go | 63 +++++-- 3 files changed, 256 insertions(+), 59 deletions(-) diff --git a/pkg/connector/programmatic_access_tokens_test.go b/pkg/connector/programmatic_access_tokens_test.go index ececc946..d218097d 100644 --- a/pkg/connector/programmatic_access_tokens_test.go +++ b/pkg/connector/programmatic_access_tokens_test.go @@ -3,15 +3,20 @@ 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) { @@ -56,14 +61,39 @@ func TestCredentialUserBuilderIssueServiceUserWithUnassignedDefaultRoleFailsBefo } } +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) } -// 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. 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) @@ -82,24 +112,33 @@ func newCredentialIssueMockServerWithShowName(t *testing.T, userType, defaultRol w.WriteHeader(http.StatusBadRequest) return } - *statements = append(*statements, request.Statement) + *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", defaultRole}, {"TYPE", userType}, + {"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 roleGranted { - data = append(data, []string{"ROLE", defaultRole}) + if mock.roleGranted { + data = append(data, []string{"ROLE", mock.defaultRole}) } _ = json.NewEncoder(w).Encode(map[string]any{ "resultSetMetadata": map[string]any{ @@ -109,6 +148,12 @@ func newCredentialIssueMockServerWithShowName(t *testing.T, userType, defaultRol "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{ @@ -122,12 +167,21 @@ func newCredentialIssueMockServerWithShowName(t *testing.T, userType, defaultRol "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{{showTokenName, "1893456000"}}, + "data": [][]string{{mock.showTokenName, expiresAt}}, }) case strings.Contains(request.Statement, "REMOVE PROGRAMMATIC ACCESS TOKEN"): _ = json.NewEncoder(w).Encode(map[string]any{"data": [][]string{}}) @@ -280,3 +334,103 @@ func TestUserResourceAdvertisesTokenAsChildResourceType(t *testing.T) { } } } + +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/users.go b/pkg/connector/users.go index 42113da6..10afa22a 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -68,20 +68,6 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild return nil, fmt.Errorf("baton-snowflake: a Snowflake user identity is required") } - // Snowflake accepts a whole number of days. Floor rather than round up so the - // provider's actual expiration is never later than the caller's deadline. - now := time.Now().UTC() - days := programmaticAccessTokenDefaultDays - expiresAt := now.AddDate(0, 0, days) - if input.ExpiresAt != nil { - remaining := input.ExpiresAt.AsTime().Sub(now) - days = int(remaining / (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) - } - tokenName := "c1-" + input.RequestID user, _, err := o.client.GetUser(ctx, nil, input.IdentityID.Resource) if err != nil { @@ -100,17 +86,38 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild ) } granted, err := o.client.RoleGrantedToUser(ctx, input.IdentityID.Resource, roleRestriction) - if err != nil { - return nil, fmt.Errorf("baton-snowflake: verify service user's default role: %w", err) - } - if !granted { + 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).Debug("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) @@ -137,22 +144,33 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild }() issuedTokens, err := o.client.ListProgrammaticAccessTokens(ctx, input.IdentityID.Resource) - if err != nil { - return nil, fmt.Errorf("baton-snowflake: read created programmatic access token expiry: %w", err) - } - found := false - for _, token := range issuedTokens { - if token.Name == tokenName { - expiresAt = token.ExpiresAt - found = true - break + 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") + 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).Debug("baton-snowflake: cannot read back token expiry: insufficient privileges", + zap.String("username", input.IdentityID.Resource)) + default: + return nil, fmt.Errorf("baton-snowflake: read created programmatic access token expiry: %w", err) } secret, err := newProgrammaticAccessTokenResource(input.IdentityID, tokenName, expiresAt) diff --git a/pkg/snowflake/programmatic_access_tokens_test.go b/pkg/snowflake/programmatic_access_tokens_test.go index 4cd737ee..2368d2d3 100644 --- a/pkg/snowflake/programmatic_access_tokens_test.go +++ b/pkg/snowflake/programmatic_access_tokens_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "testing" "github.com/stretchr/testify/require" @@ -33,8 +34,8 @@ func TestCreateProgrammaticAccessTokenStatementShape(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - var statements []string - server := newTokenCreateServer(t, &statements) + recorder := &statementRecorder{} + server := newTokenCreateServer(t, recorder) defer server.Close() client, err := New(server.URL, JWTConfig{}, server.Client()) require.NoError(t, err) @@ -44,6 +45,7 @@ func TestCreateProgrammaticAccessTokenStatementShape(t *testing.T) { ) require.NoError(t, err) require.Equal(t, "the-secret", secret) + statements, _ := recorder.snapshot() require.Equal(t, []string{tc.want}, statements) }) } @@ -57,11 +59,31 @@ func TestCreateProgrammaticAccessTokenRejectsNonPositiveExpiry(t *testing.T) { 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]. -var lastRole string - -func newTokenCreateServer(t *testing.T, statements *[]string) *httptest.Server { +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) @@ -76,8 +98,7 @@ func newTokenCreateServer(t *testing.T, statements *[]string) *httptest.Server { w.WriteHeader(http.StatusBadRequest) return } - *statements = append(*statements, request.Statement) - lastRole = request.Role + recorder.record(request.Statement, request.Role) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "resultSetMetadata": map[string]any{ @@ -136,41 +157,45 @@ func TestExecuteStatementClassifiesDenialOnEitherLeg(t *testing.T) { // 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) { - var statements []string - lastRole = "" - server := newTokenCreateServer(t, &statements) + 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) - require.Equal(t, UserAdminRole, lastRole) + _, roles := recorder.snapshot() + require.Equal(t, []string{UserAdminRole}, roles) }) t.Run("remove", func(t *testing.T) { - var statements []string - lastRole = "" - server := newTokenCreateServer(t, &statements) + 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")) - require.Equal(t, UserAdminRole, lastRole) + _, roles := recorder.snapshot() + require.Equal(t, []string{UserAdminRole}, roles) }) t.Run("read-only statements do not force a role", func(t *testing.T) { - var statements []string - lastRole = "sentinel" - server := newTokenCreateServer(t, &statements) + 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") - require.Empty(t, lastRole, "reads should run as the session's default role") + _, roles := recorder.snapshot() + require.Equal(t, []string{""}, roles, "reads should run as the session's default role") }) } From 3254fbd273dd217aa1fe63bcba40aad81e561f49 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:12:15 +0000 Subject: [PATCH 9/9] fix(pat): surface issuance degradations at Warn and correct the docs note Both privilege degradations added in #149 logged at Debug, so at the default level an operator saw nothing distinguishing a locally computed expiry from one Snowflake actually returned. Per the repo's log-level rules a skip-and-continue degradation is Warn, and both fire once per issuance rather than per resource, so there is no volume concern. The read-back message now also carries the estimated expiry it substituted. The token builder's own skip in ListProgrammaticAccessTokens stays at Debug: that one fires once per user per sync and is the per-resource case the same rules keep quiet. The service-user note in the docs still claimed the connector verifies the default-role grant before issuing. That has been best-effort since #149, so it is softened to match. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Opus 5 --- docs/connector.mdx | 2 +- pkg/connector/users.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 73356e7d..b912e582 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -37,7 +37,7 @@ Token lifetime is set by the requester. Snowflake accepts whole days only, and t -**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 actually granted to the user first. If the user has no default role, or the default role is not granted, issuance fails before any token is created. +**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. diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 10afa22a..83de0e1d 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -97,7 +97,7 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild // 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).Debug("baton-snowflake: skipping default-role check: insufficient privileges", + 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) @@ -167,8 +167,9 @@ func (o *credentialUserBuilder) Issue(ctx context.Context, input *connectorbuild // 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).Debug("baton-snowflake: cannot read back token expiry: insufficient privileges", - zap.String("username", input.IdentityID.Resource)) + 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) }