feat: default to key-pair login for Snowflake workspaces - #93
feat: default to key-pair login for Snowflake workspaces#93jirkasemmler wants to merge 3 commits into
Conversation
Omitted or 'default' loginType on snowflake resolves server-side to the deprecated password-based snowflake-legacy-service login. The client now defaults to snowflake-service-keypair: it generates an RSA key pair locally, sends the public key and returns the private key in connection.privateKey. Explicit snowflake-legacy-service still works. Co-Authored-By: Claude Fable 5 <[email protected]>
jirkasemmler
left a comment
There was a problem hiding this comment.
Automated review (Claude Code). Overall the change is sound and well documented; the inline comments below are ordered by severity. Two findings that don't anchor to diff lines:
- Breaking change / versioning: existing callers doing
create()['connection']['password']on snowflake projects will now get aKeyError— the default silently flips to key-pair login. This deserves a major version bump and a prominent changelog entry. - Stale docs:
detail()still says "the password to the workspace can only be retrieved when the workspace is created", andreset_password()is no longer a meaningful recovery path for the new default key-pair workspaces (the correct path isset_public_keywith a freshly generated pair). Both docstrings should be updated.
| private_key, public_key = _generate_rsa_key_pair() | ||
| if login_type is not None: | ||
| # the API rejects loginType without an explicit backend | ||
| backend = effective_backend |
There was a problem hiding this comment.
Unclear server 400 when the default backend can't be resolved. If the token verify response has no owner.defaultBackend (e.g. a restricted token), _get_default_backend() returns None, so with an explicit login_type this sends loginType without backend (requests drops None values from form data) — exactly the combination the comment above says the API rejects. The caller gets an obscure server-side 400.
Suggestion: if effective_backend is None and login_type is not None, raise a clear client-side ValueError explaining that a backend must be passed explicitly.
There was a problem hiding this comment.
Fixed in 478659d — create() now raises a clear ValueError when login_type is set but neither an explicit backend nor a resolvable project default backend is available.
| requests.HTTPError: If the API request fails. | ||
| """ | ||
| private_key = None | ||
| effective_backend = backend or self._get_default_backend() |
There was a problem hiding this comment.
Extra HTTP round-trip on every create() without an explicit backend. Every call now pays a GET /v2/storage/tokens/verify, even when the result is discarded (non-snowflake projects) or login_type is explicitly 'none'/legacy. Bulk workspace creation performs N identical verify calls, and a transient verify failure now fails a create() that previously made exactly one request.
Suggestion: cache the resolved value on the instance (e.g. self._default_backend, resolved lazily on first use) — a project's default backend is effectively immutable for the client's lifetime.
There was a problem hiding this comment.
Fixed in 478659d — the resolved default backend is now cached on the instance (self._default_backend, resolved lazily on first use), so repeated create() calls pay the verify round-trip only once. Covered by a new test.
| private_key = None | ||
| effective_backend = backend or self._get_default_backend() | ||
| if effective_backend == BACKEND_SNOWFLAKE: | ||
| if login_type in (None, LOGIN_TYPE_DEFAULT): |
There was a problem hiding this comment.
Explicit login_type='default' is silently rewritten to key-pair. 'default' is a valid API value (password login), so a caller explicitly asking for it gets a key-pair workspace instead, and their subsequent response['connection']['password'] read raises KeyError. It is documented in the docstring, but the more conventional behavior would be to only apply the default when login_type is None and pass an explicit 'default' through to the server. If the current behavior is intentional, consider keeping it — just flagging the trade-off.
There was a problem hiding this comment.
Intentional — the goal of this change is that no code path silently produces a deprecated password workspace, and an explicit 'default' resolves server-side to the same snowflake-legacy-service password login as an omitted value. This mirrors how connection's own test tooling (StorageApiTestCase::prepareWorkspaceCreateOptions) rewrites null/default. Callers who genuinely want a password workspace must opt in with login_type='snowflake-legacy-service'. Documented in the docstring.
| return self._post(self.base_url, data=body) | ||
| response = self._post(self.base_url, data=body) | ||
| if private_key is not None: | ||
| response.setdefault('connection', {})['privateKey'] = private_key |
There was a problem hiding this comment.
Client-generated private key is spliced into the server response. After this, connection.privateKey is indistinguishable from server-returned data; callers that log or persist the whole create() response (a common pattern) will write an unencrypted PKCS#8 private key into logs/storage. Consider returning it separately (e.g. a tuple or a dedicated documented key outside connection), or at minimum call this risk out in the changelog.
There was a problem hiding this comment.
Keeping as-is, deliberately: credentials living under connection is the established contract of this client (password workspaces return connection.password the same way), and connection's PHP test tooling splices the locally generated privateKey into connection identically. Returning it out-of-band would break the drop-in usage for callers switching from password to privateKey. The logging/persistence risk applies equally to the server-returned password today; the docstring calls out that the private key is only available at creation. Will flag it in the release notes.
| @@ -91,18 +95,109 @@ def test_detail_inexsitent_workspace(self): | |||
| @responses.activate | |||
| def test_create(self): | |||
There was a problem hiding this comment.
Missing coverage: backend omitted on a non-snowflake project. Every test either forces backend explicitly or mocks verify to return snowflake. A regression in the branch where verify returns e.g. bigquery (must send neither loginType nor publicKey, backend stays None) would pass the suite unnoticed. Suggest adding a test with verify_token_response patched to a non-snowflake defaultBackend.
There was a problem hiding this comment.
Fixed in 478659d — added test_create_non_snowflake_default_backend_unchanged: verify resolves bigquery and the create request must contain neither backend, loginType nor publicKey.
| } | ||
| } | ||
|
|
||
| keypair_create_response = { |
There was a problem hiding this comment.
Nit: keypair_create_response is a 25-line copy of create_response differing only in the connection block. Deriving it keeps the two from drifting:
keypair_create_response = {
**create_response,
'connection': {
**{k: v for k, v in create_response['connection'].items() if k != 'password'},
'loginType': 'snowflake-service-keypair',
},
}There was a problem hiding this comment.
Fixed in 478659d — keypair_create_response is now derived from create_response (password dropped, loginType added).
- raise a clear ValueError when login_type is used but the backend cannot be resolved (the API rejects loginType without backend) - cache the resolved default backend on the instance to avoid repeated tokens/verify calls - update stale detail()/reset_password() docstrings for key-pair workspaces - derive keypair_create_response mock from create_response - add tests for non-snowflake default backend, verify caching and the unresolvable-backend error Co-Authored-By: Claude Fable 5 <[email protected]>
|
Re the two general findings:
|
jirkasemmler
left a comment
There was a problem hiding this comment.
Re-review of 478659d (Claude Code): all actionable findings from the previous review are addressed and verified — the mock suite passes locally (19 passed).
- ✅ Clear
ValueErrorwhenlogin_typeis given but the default backend can't be resolved (covered bytest_create_login_type_without_resolvable_backend_raises) - ✅
tokens/verifyresult cached per instance (covered bytest_create_caches_default_backend) - ✅ Non-snowflake default backend path covered (
test_create_non_snowflake_default_backend_unchanged) - ✅ Stale
detail()/reset_password()docstrings updated - ✅
keypair_create_responsederived fromcreate_response
Two items were intentionally left as-is and are fine, but should be called out in the release notes: the breaking default (no more connection.password on snowflake — versioning is git-tag driven, so tag the release as a major bump) and the client-generated connection.privateKey in the response (callers logging whole responses will log the key). No new issues introduced by the fix commit. LGTM.
There was a problem hiding this comment.
Pull request overview
This PR changes Workspaces.create() to avoid implicitly creating deprecated password-based Snowflake workspaces by defaulting Snowflake workspaces to key-pair authentication, including local RSA key generation and default-backend resolution via token verification when backend is omitted.
Changes:
- Default omitted/
defaultSnowflakelogin_typetosnowflake-service-keypair, generating an RSA-2048 key pair locally when needed and returning the private key to the caller. - Resolve and cache the project’s default backend via
GET /tokens/verifywhenbackendis not provided. - Add/adjust mock tests and mock responses; add
cryptographyas a runtime dependency.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
kbcstorage/workspaces.py |
Implements Snowflake key-pair defaulting, key generation, and default-backend resolution/caching. |
tests/mocks/test_workspaces.py |
Extends mock coverage for Snowflake key-pair default behavior and default-backend resolution. |
tests/mocks/workspace_responses.py |
Adds a mock create response variant for key-pair Snowflake workspaces. |
pyproject.toml |
Adds the cryptography dependency required for RSA key generation. |
Suppressed comments (2)
tests/mocks/test_workspaces.py:209
- parse_qs() defaults to keep_blank_values=False, which can hide cases where the client sends an empty parameter (e.g., backend=). Using keep_blank_values=True makes these assertions stricter and prevents false positives.
self.ws.create()
request_body = parse_qs(responses.calls[1].request.body)
assert 'backend' not in request_body
assert 'loginType' not in request_body
assert 'publicKey' not in request_body
tests/mocks/test_workspaces.py:278
- parse_qs() defaults to keep_blank_values=False, which can hide cases where the client sends an empty parameter (e.g., loginType=). Using keep_blank_values=True makes these assertions stricter and prevents false positives.
self.ws.create(backend='bigquery')
request_body = parse_qs(responses.calls[0].request.body)
assert request_body['backend'] == ['bigquery']
assert 'loginType' not in request_body
assert 'publicKey' not in request_body
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @@ -94,7 +162,22 @@ def create(self, backend=None, timeout=None, login_type=None, public_key=None, r | |||
| 'readOnlyStorageAccess': str(read_all_objects).lower() # convert bool to lowercase true or false | |||
There was a problem hiding this comment.
Fixed in ecb6a26 — the body is now filtered with the same if v is not None comprehension as triggers.py. (For the record, requests drops None values from form data, so they were never serialized as empty parameters — but the explicit filter states the intent and makes the stricter test assertions meaningful.)
| if self._default_backend is None: | ||
| token_info = Tokens(self.root_url, self.token).verify() | ||
| self._default_backend = (token_info.get('owner') or {}).get('defaultBackend') | ||
| return self._default_backend |
There was a problem hiding this comment.
Fixed in ecb6a26 — _default_backend now starts at a module-level sentinel, so a resolved-but-missing default backend is memoized too. The ValueError test now calls create() twice and asserts verify ran only once.
- filter None values from the create request body (triggers.py pattern) - cache the missing default backend too, using a sentinel - stricter test assertions with keep_blank_values=True - fix docstring typo Co-Authored-By: Claude Fable 5 <[email protected]>
44a8317 to
ecb6a26
Compare
Why
Creating a Snowflake workspace without an explicit
loginType(or withdefault) resolves server-side to the deprecated password-basedsnowflake-legacy-servicelogin. Connection already logs this as a deprecation and already-migrated Snowflake accounts reject legacy service users entirely, so the client's default behavior of silently producing password workspaces needs to go away.Context
The target behavior mirrors how this is already solved in the
connectionrepo: its test tooling rewrites an omitted/defaultlogin type on Snowflake tosnowflake-service-keypair, generates an RSA key pair on the client, sends the public key with the create request and keeps the private key locally.Approach
Workspaces.create()now:login_typeNone/'default'tosnowflake-service-keypairwhen the backend is Snowflake,cryptographydependency) when nopublic_keyis supplied, sends the public key and returns the private key (PKCS#8 PEM, as expected by Snowflake drivers) inresponse['connection']['privateKey']— the private key never leaves the client and cannot be retrieved later,GET /tokens/verifywhenbackendis omitted, because the API rejectsloginTypewithout an explicitbackend,login_type='snowflake-legacy-service'untouched, so password workspaces remain available as an opt-in, and keeps all non-Snowflake backends unchanged.Impact
create()on Snowflake get key-pair credentials by default; code readingconnection['password']from the create response must switch toconnection['privateKey']or passlogin_type='snowflake-legacy-service'explicitly.create()without an explicitbackendmakes one extra API call (tokens/verify) to resolve the default backend.cryptography.Testing
KBC_TEST_API_URL/KBC_TEST_TOKEN).🤖 Generated with Claude Code