Skip to content

Fix service-user issuance: quoted roles, USERADMIN on mutations, async denial classification - #148

Merged
highb merged 2 commits into
santhosh.kumar/credential-issuancefrom
c1-squire-dev/IGA-3962-statement-error-classification
Aug 25, 2026
Merged

Fix service-user issuance: quoted roles, USERADMIN on mutations, async denial classification#148
highb merged 2 commits into
santhosh.kumar/credential-issuancefrom
c1-squire-dev/IGA-3962-statement-error-classification

Conversation

@c1-squire-dev

@c1-squire-dev c1-squire-dev Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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 USER wraps a mixed-case or spaced identifier in double quotes; DESCRIBE USER reports DEFAULT_ROLE bare. RoleGrantedToUser compared the two raw strings, so a granted role read as ungranted:

DESCRIBE USER        -> DEFAULT_ROLE = Mixed Case Role
SHOW GRANTS TO USER  -> name = "Mixed Case Role"     <- literal quotes

With the target's DEFAULT_ROLE set to Mixed Case Role, issuance failed with default 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 through unquoteSnowflakeIdentifier and compare with EqualFold. The full issue → delete → verify-absent lifecycle passes against that same user after the change.

2. ALTER USER ran without a role

ALTER USER ... ADD/REMOVE PROGRAMMATIC ACCESS TOKEN executed under the session's default role. SetUserDisabled (pkg/snowflake/user.go:294-299), CreateUserREST and DeleteUserREST all force USERADMIN, with a comment stating why: the session default is not guaranteed to hold ALTER USER on other users. These two statements now do the same, via executeStatementAsUserAdmin. Reads are untouched and still run as the session role.

3. Access-control denials on the async statement leg

executeStatement classified 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/003001 arriving 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 USER column shape

The finding says the statement returns created_on, role, granted_to, grantee_name, granted_by and therefore that granted_on and name do not exist. Against a live account it returns a superset:

cols=[created_on,privilege,granted_on,name,role,granted_to,grantee_name,grant_option,granted_by]
granted_on=ROLE  name=BATON_TARGET_ROLE  granted_to=USER  grantee_name=BATON_TARGET_1

The narrower shape is the one for SHOW GRANTS **OF ROLE**, which is what account_role.go documents — a different statement. So the columns the code reads do exist, and issuance was never failing with row 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 Max in Issue. The reading is right that Issue checks only the lower bound, but the SDK validates both bounds against the advertised descriptor before Issue is called (credential_issue_validation.go:93 and :96, from builder.IssueCredential). A 400-day request fails locally with InvalidArgument: requested expiry exceeds connector maximum and 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 < 1 check 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 EXISTS after REMOVE 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 the TOKEN descriptor and the provider owns it — Issue honours input.ExpiresAt and 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.

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>
@linear-code

linear-code Bot commented Aug 25, 2026

Copy link
Copy Markdown

IGA-3962

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>
@c1-squire-dev c1-squire-dev Bot changed the title Classify access-control denials on the async statement leg Fix service-user issuance: quoted roles, USERADMIN on mutations, async denial classification Aug 25, 2026
@highb
highb merged commit 74315c4 into santhosh.kumar/credential-issuance Aug 25, 2026
9 of 10 checks passed
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