Fix service-user issuance: quoted roles, USERADMIN on mutations, async denial classification - #148
Merged
highb merged 2 commits intoAug 25, 2026
Conversation
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] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
highb
merged commit Aug 25, 2026
74315c4
into
santhosh.kumar/credential-issuance
9 of 10 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three fixes in the SQL layer, each confirmed against a live Snowflake account rather than argued from documentation.
1. Quoted role identifiers blocked issuance entirely
SHOW GRANTS TO USERwraps a mixed-case or spaced identifier in double quotes;DESCRIBE USERreportsDEFAULT_ROLEbare.RoleGrantedToUsercompared the two raw strings, so a granted role read as ungranted:With the target's
DEFAULT_ROLEset toMixed Case Role, issuance failed withdefault role "Mixed Case Role" is not granted to the user— for a role that was granted. No user whose default role is mixed-case or spaced could be issued a token at all. Both sides now go throughunquoteSnowflakeIdentifierand compare withEqualFold. The full issue → delete → verify-absent lifecycle passes against that same user after the change.2.
ALTER USERran without a roleALTER USER ... ADD/REMOVE PROGRAMMATIC ACCESS TOKENexecuted under the session's default role.SetUserDisabled(pkg/snowflake/user.go:294-299),CreateUserRESTandDeleteUserRESTall forceUSERADMIN, with a comment stating why: the session default is not guaranteed to holdALTER USERon other users. These two statements now do the same, viaexecuteStatementAsUserAdmin. Reads are untouched and still run as the session role.3. Access-control denials on the async statement leg
executeStatementclassified a denial only on the POST leg. A statement that goes async reports its outcome on the follow-up GET, and that leg returned the raw error — so a 422/003001arriving there was indistinguishable from a real failure and aborted the sync rather than skipping the object. Both legs now classify. The regression test drives a denial down each leg independently and fails on the GET case without the change.On the reported
SHOW GRANTS TO USERcolumn shapeThe finding says the statement returns
created_on, role, granted_to, grantee_name, granted_byand therefore thatgranted_onandnamedo not exist. Against a live account it returns a superset:The narrower shape is the one for
SHOW GRANTS **OF ROLE**, which is whataccount_role.godocuments — a different statement. So the columns the code reads do exist, and issuance was never failing withrow type granted_on not found; it was failing on the quoting, which is fixed above. The column names are left as they were.Not taken
Clamping the advertised expiry
MaxinIssue. The reading is right thatIssuechecks only the lower bound, but the SDK validates both bounds against the advertised descriptor beforeIssueis called (credential_issue_validation.go:93and:96, frombuilder.IssueCredential). A 400-day request fails locally withInvalidArgument: requested expiry exceeds connector maximumand never reaches Snowflake — verified live, zero tokens created. Adding the check here would be unreachable code asserting a bound the descriptor already owns.The remaining
days < 1check is not redundant in the same way: it guards a provider constraint that survives the flooring step, and it covers the direct-call path the package's own tests use.IF EXISTSafterREMOVE PROGRAMMATIC ACCESS TOKEN. Accepted by the live API, HTTP 200 both when the token exists and when it does not, so the statement is idempotent as written.Why expiry differs from the Datadog connector
Recording this because the divergence reads as an inconsistency and is the opposite.
Datadog does not support credential expiry in-app, so that connector declares no expiry capability and ConductorOne owns the clock. Snowflake has a real create-time TTL, so this connector declares
IssuanceExpiryCapability{Min: 24h, Max: 365d}on theTOKENdescriptor and the provider owns it —Issuehonoursinput.ExpiresAtand floors to whole days so the token never outlives the requested deadline.Between them the two connectors exercise both arms of the expiry contract on purpose.