Skip to content

feat: default to key-pair login for Snowflake workspaces - #93

Open
jirkasemmler wants to merge 3 commits into
masterfrom
snowflake-keypair-workspace-default
Open

feat: default to key-pair login for Snowflake workspaces#93
jirkasemmler wants to merge 3 commits into
masterfrom
snowflake-keypair-workspace-default

Conversation

@jirkasemmler

Copy link
Copy Markdown

Why

Creating a Snowflake workspace without an explicit loginType (or with default) resolves server-side to the deprecated password-based snowflake-legacy-service login. 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 connection repo: its test tooling rewrites an omitted/default login type on Snowflake to snowflake-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:

  • rewrites login_type None/'default' to snowflake-service-keypair when the backend is Snowflake,
  • generates an RSA-2048 key pair locally (new cryptography dependency) when no public_key is supplied, sends the public key and returns the private key (PKCS#8 PEM, as expected by Snowflake drivers) in response['connection']['privateKey'] — the private key never leaves the client and cannot be retrieved later,
  • resolves the project's default backend via GET /tokens/verify when backend is omitted, because the API rejects loginType without an explicit backend,
  • leaves an explicit login_type='snowflake-legacy-service' untouched, so password workspaces remain available as an opt-in, and keeps all non-Snowflake backends unchanged.

Impact

  • Callers of create() on Snowflake get key-pair credentials by default; code reading connection['password'] from the create response must switch to connection['privateKey'] or pass login_type='snowflake-legacy-service' explicitly.
  • create() without an explicit backend makes one extra API call (tokens/verify) to resolve the default backend.
  • New runtime dependency: cryptography.

Testing

  • Mock tests cover: default-backend resolution via token verify, explicit Snowflake backend defaulting to key-pair, caller-supplied public key passthrough (no private key injected), explicit legacy login passthrough, and unchanged non-Snowflake behavior.
  • All 50 mock tests pass; flake8 with the repo config is clean.
  • Functional tests against a live stack were not run (require KBC_TEST_API_URL/KBC_TEST_TOKEN).

🤖 Generated with Claude Code

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 jirkasemmler left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:

  1. Breaking change / versioning: existing callers doing create()['connection']['password'] on snowflake projects will now get a KeyError — the default silently flips to key-pair login. This deserves a major version bump and a prominent changelog entry.
  2. Stale docs: detail() still says "the password to the workspace can only be retrieved when the workspace is created", and reset_password() is no longer a meaningful recovery path for the new default key-pair workspaces (the correct path is set_public_key with a freshly generated pair). Both docstrings should be updated.

Comment thread kbcstorage/workspaces.py
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 478659dcreate() now raises a clear ValueError when login_type is set but neither an explicit backend nor a resolvable project default backend is available.

Comment thread kbcstorage/workspaces.py
requests.HTTPError: If the API request fails.
"""
private_key = None
effective_backend = backend or self._get_default_backend()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread kbcstorage/workspaces.py
private_key = None
effective_backend = backend or self._get_default_backend()
if effective_backend == BACKEND_SNOWFLAKE:
if login_type in (None, LOGIN_TYPE_DEFAULT):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread kbcstorage/workspaces.py
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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):

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 = {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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',
    },
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 478659dkeypair_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]>
@jirkasemmler

Copy link
Copy Markdown
Author

Re the two general findings:

  1. Versioning/changelog: agreed this is breaking for callers reading connection['password'] on Snowflake. Versioning here is tag-driven (setuptools-git-versioning), so the release for this PR should be a major bump with the migration note (passwordprivateKey, or opt-in login_type='snowflake-legacy-service'). The PR description carries the breaking-change note for the release notes.
  2. Stale docs: fixed in 478659ddetail() now talks about credentials generically (password or private key, only available at creation), and reset_password() documents that it only applies to password-based login types and points key-pair workspaces to set_public_key() rotation.

@jirkasemmler jirkasemmler left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 ValueError when login_type is given but the default backend can't be resolved (covered by test_create_login_type_without_resolvable_backend_raises)
  • tokens/verify result cached per instance (covered by test_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_response derived from create_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.

@jirkasemmler
jirkasemmler requested review from zajca and a lite review from Copilot August 18, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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/default Snowflake login_type to snowflake-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/verify when backend is not provided.
  • Add/adjust mock tests and mock responses; add cryptography as 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.

Comment thread tests/mocks/test_workspaces.py Outdated
Comment thread kbcstorage/workspaces.py Outdated
Comment on lines 158 to 162
@@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.)

Comment thread kbcstorage/workspaces.py Outdated
Comment on lines +177 to +180
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread tests/mocks/test_workspaces.py Outdated
- 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]>
@jirkasemmler
jirkasemmler force-pushed the snowflake-keypair-workspace-default branch from 44a8317 to ecb6a26 Compare August 19, 2026 10:41
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.

2 participants