Skip to content

Add Snowflake credential issuance - #144

Open
santhosh-c1 wants to merge 1 commit into
mainfrom
santhosh.kumar/credential-issuance
Open

Add Snowflake credential issuance#144
santhosh-c1 wants to merge 1 commit into
mainfrom
santhosh.kumar/credential-issuance

Conversation

@santhosh-c1

Copy link
Copy Markdown

Summary

  • issue expiring Snowflake programmatic access tokens
  • register discoverable secret resources with revoke support
  • expose issuance capability only when secret sync is enabled

Verification

Live tenant verification is pending Snowflake credentials.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment on lines +28 to +31
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bug: programmatic_access_token will never actually sync. The SDK only invokes a child resource type's List with a parent when the parent resource carries a v2.ChildResourceType annotation (syncer.childResourceTypeIDs); the top-level SyncResourcesOp calls List with parentID == nil, which returns nothing here. userResource (pkg/connector/users.go:161-163) only adds the annotation for rsaPublicKeyResourceType.Id. Since the issuance descriptor advertises CREDENTIAL_RESOURCE_MODE_DISCOVERABLE ("the credential is expected to appear in subsequent resource syncs"), issued tokens will never show up in inventory and will never be deletable. Add programmaticAccessTokenResourceType.Id as a child resource type on the user resource when syncSecrets is set.

Comment thread pkg/connector/users.go
Comment on lines +88 to +105
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")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Security: every failure path after CreateProgrammaticAccessToken succeeds leaves a live, long-lived Snowflake credential orphaned — the plaintext is discarded and no secret resource is ever recorded, so C1 has no handle to revoke it (and, per the other finding, a sync won't discover it either). The SDK explicitly does not auto-retry issuance (credentials.go:186-188), so this is terminal. The expiresAt.After(input.ExpiresAt) check is a realistic trigger: Snowflake anchors expiry at token-creation time, so with days = floor(remaining/24h) the provider expiry is createdAt + days*24h, which exceeds the requested deadline whenever the request's remaining lifetime is (near) an exact multiple of 24h. Wrap these paths so a failure calls RemoveProgrammaticAccessToken(ctx, user, tokenName) before returning the error.

Comment on lines +50 to +53
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the token secret is read by hardcoded positional index result.Data[0][1] rather than by column name. If Snowflake ever reorders or inserts a column in the ALTER USER ... ADD PROGRAMMATIC ACCESS TOKEN result set, this silently returns the wrong value (e.g. token_name) as the credential instead of failing. result.ResultSetMetadata.GetStringValueFromRow(result.Data[0], "token_secret") is already used a few lines above in ListProgrammaticAccessTokens and would fail loudly instead.

Comment on lines +16 to +20
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: no graceful degradation for access-control denials. SHOW USER PROGRAMMATIC ACCESS TOKENS FOR USER requires ownership/MONITOR on the target user; Snowflake answers 422 with code 003001 for a denial, and this returns the raw error so a single unprivileged user aborts the whole sync. UserRsa and ListSecrets in this package already handle that via isAccessControlDenial / IsInsufficientPrivileges and skip-and-continue (see pkg/connector/rsa.go:91-101). Also consider wrapping the error with the baton-snowflake: prefix rather than returning it bare in the builder's List.

@github-actions

Copy link
Copy Markdown
Contributor

Connector PR Review: Add Snowflake credential issuance

Blocking Issues: 2 | Suggestions: 5 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base ffb38dec42b7.
Review mode: full
View review run

Review Summary

Reviewed the full PR diff (6 files, +361/-1) adding Snowflake programmatic access token issuance: a new credentialUserBuilder issuer, a programmatic_access_token secret resource type with a deleter, and the client-side SQL statements. No dependency manifest changes. SQL identifier quoting and the SDK issuance/output contract (identity_id, expires_at, resource mode, secret resource type) all check out, but the new resource type is never reachable during sync and issuance can strand a live credential on partial failure.

Security Issues

  • pkg/connector/users.go:88-105 — a failure after CreateProgrammaticAccessToken succeeds (list error, token-not-found, or the provider-expiry check) discards the plaintext and returns an error, leaving a live long-lived Snowflake token that C1 has no record of and cannot revoke. The SDK does not auto-retry issuance, so this is terminal.

Correctness Issues

  • pkg/connector/programmatic_access_tokens.go:28-31 / pkg/connector/users.go:161-163programmatic_access_token is registered as a syncer but no parent resource declares it via a v2.ChildResourceType annotation, so List is only ever called with a nil parent and returns nothing. Issued tokens never appear in a sync, which breaks the advertised CREDENTIAL_RESOURCE_MODE_DISCOVERABLE contract and leaves no resource to delete.

Suggestions

  • pkg/snowflake/programmatic_access_tokens.go:50-53 — the token secret is read by hardcoded positional index result.Data[0][1] instead of by token_secret column name; a column reorder would silently return the wrong value as the credential.
  • pkg/snowflake/programmatic_access_tokens.go:16-20 — no graceful degradation for 422/003001 access-control denials, unlike UserRsa/ListSecrets; one unprivileged user aborts the entire sync. Error is also returned bare without the baton-snowflake: prefix in the builder's List.
  • pkg/snowflake/programmatic_access_tokens.go:21-33ResultSetMetadata.PartitionInfo is ignored, so only partition 0 is read (account_role.go handles partitions for its SHOW result sets).
  • pkg/connector/users.go:71-81 — the expiresAt computed before the API call is dead: every reachable return path either overwrites it from ListProgrammaticAccessTokens or errors out first.
  • docs/connector.mdx:13-24, 41 — capabilities table and the required-privileges tip are not updated for programmatic access tokens or credential issuance (D1/D3). Test coverage is also thin: Issue (expiry flooring, not-found path) and the client statement builders have no tests, and parseProgrammaticAccessTokenID only covers the happy path.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Security Issues

In `pkg/connector/users.go`:
- Around line 88-105: In credentialUserBuilder.Issue, every error path after
  CreateProgrammaticAccessToken succeeds (ListProgrammaticAccessTokens failure,
  !found, and the "provider expiry exceeds requested expiry" check) returns an
  error while the token remains live in Snowflake, with the plaintext discarded
  and no secret resource recorded. Add a cleanup path: on any post-create
  failure, call o.client.RemoveProgrammaticAccessToken(ctx,
  input.IdentityID.Resource, tokenName) (log but do not mask the original error)
  before returning. Also re-check the expiry math: Snowflake anchors PAT expiry
  at creation time, so days = floor(remaining/24h) yields createdAt +
  days*24h, which can exceed the requested deadline when remaining is close to
  an exact multiple of 24h; subtract a day (or clamp) so the check cannot fire
  for a normally-formed request.

## Correctness Issues

In `pkg/connector/users.go`:
- Around line 161-163: userResource only adds a v2.ChildResourceType annotation
  for rsaPublicKeyResourceType.Id when syncSecrets is true. The SDK discovers
  child resources solely from this annotation
  (sync/syncer.go childResourceTypeIDs); the top-level SyncResourcesOp calls
  List with a nil parent, which programmaticAccessTokenBuilder.List ignores.
  Add rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId:
  programmaticAccessTokenResourceType.Id}) alongside the RSA one so tokens are
  enumerated per user. Without it the advertised
  CREDENTIAL_RESOURCE_MODE_DISCOVERABLE mode is unsatisfiable.

## Suggestions

In `pkg/snowflake/programmatic_access_tokens.go`:
- Around line 50-53: Replace the positional read result.Data[0][1] with a
  name-based lookup:
  result.ResultSetMetadata.GetStringValueFromRow(result.Data[0],
  "token_secret"), keeping the len(result.Data) != 1 guard, so a Snowflake
  column reorder fails loudly instead of returning the wrong value as the
  credential.
- Around line 16-20: ListProgrammaticAccessTokens returns the raw error on a
  privilege denial. Follow the pattern in ListSecrets/UserRsa: pass the
  *http.Response and decoded SnowflakeError to isAccessControlDenial (or expose
  IsInsufficientPrivileges to the caller) and let the connector-side List
  skip-and-continue with a Debug log instead of failing the sync. Wrap the
  remaining error with the `baton-snowflake:` prefix in
  programmaticAccessTokenBuilder.List.
- Around line 21-33: The loop only reads result.Data, ignoring
  result.ResultSetMetadata.PartitionInfo, so rows beyond partition 0 are
  silently dropped. Either fetch remaining partitions via
  GetStatementPartition (as ListAccountRoleGrantees does) or add a comment
  justifying single-partition reads for this statement.

In `pkg/connector/users.go`:
- Around line 71-81: The pre-call `expiresAt` computation is dead — it is always
  overwritten from the ListProgrammaticAccessTokens result or the function
  returns first. Reduce this block to computing `days` only, and derive
  expiresAt solely from the provider response.

In `docs/connector.mdx`:
- Around line 13-24 and line 41: Add a "Programmatic Access Tokens" row to the
  Capabilities table and document the Snowflake privileges required to run
  SHOW USER PROGRAMMATIC ACCESS TOKENS and ALTER USER ... ADD/REMOVE
  PROGRAMMATIC ACCESS TOKEN, plus the fact that credential issuance is only
  exposed when "Sync secrets" is enabled.

In `pkg/connector/programmatic_access_tokens_test.go`:
- Around line 1-63: Add coverage for credentialUserBuilder.Issue (expiry
  flooring, the token-not-found path, and cleanup on failure) against the
  existing mock-server harness, and add negative cases for
  parseProgrammaticAccessTokenID (bad base64, empty parts, names containing
  `"` or `;`).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

@c1-squire-dev

c1-squire-dev Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Squire (openai/gpt-5.6-terra): The credential-issuance implementation and its lifecycle coverage are on the right track. One service-user detail needs a follow-up: Snowflake requires ROLE_RESTRICTION by default for SERVICE, SERVICE_AGENT, and LEGACY_SERVICE PATs, and the selected role must be granted to that user.

I opened #145 against this PR branch. It uses the user’s default role after verifying the grant, fails before token creation when no suitable role exists, and adds the CredentialIssuerV2 compile-time guard plus mock coverage. Snowflake’s PAT documentation is the primary source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant