Skip to content

Commit f7cbe13

Browse files
costroucclaudearonatkins
authored
Feat support snowflake spcs OIDC (#717)
* Minimal bits to make Snowflake SPCS OIDC authentication work This commit is mainly meant as an example to complement changes in how we will be performing authentication within the Snowflake Posit Team Native Application. When / if that PR of work for OIDC goes through this will serve as a good example of how it can be supported. I think this PR also highlights the importance of OIDC device flow authentication which is supported in PPM https://packagemanager.rstudio.com/__docs__/admin/appendix//cli/rspm_login_sso.html which would again eliminate the need for an api key. I REALLY like how this package uses the snow command to generate the jwt used for snowflake ingress as this means our Posit libraries don't have to re-implement the snowflake authentication. Going to put this PR in draft and will contribute more after I share this with our team tomorrow at Standup. * Refactor SPCS authentication to align with codebase patterns This commit refines the Snowflake SPCS (Snowpark Container Services) OIDC authentication implementation to better align with existing codebase patterns and improve type safety. Changes: - Make SPCSConnectServer.api_key Optional[str] to match RSConnectServer - Add comprehensive docstring to SPCSConnectServer class explaining SPCS deployment and authentication approach - Reorder RSConnectExecutor server type detection to check for snowflake_connection_name first, as SPCS is more specific than generic Connect deployment - Ensure api_key is passed to SPCSConnectServer in all instantiations (RSConnectExecutor.__init__ and validate_spcs_server) - Add null check before setting X-RSC-Authorization header to fix type checking error - Update all test cases in SPCSConnectServerTestCase to pass api_key parameter and verify it's set correctly All SPCS-specific tests pass. The implementation now follows the established patterns for server authentication while maintaining backward compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * docs: update CHANGELOG for SPCS authentication improvements Add changelog entry documenting the fix for Snowflake SPCS authentication to properly handle API keys and align with codebase patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * metadata reordering to allow snowflake+api key * store api key when storing spcs server * Fix snowflake server store get --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Aron Atkins <[email protected]>
1 parent 731a5de commit f7cbe13

File tree

6 files changed

+40
-23
lines changed

6 files changed

+40
-23
lines changed

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased] - ??
99

10+
### Fixed
11+
12+
- Snowflake SPCS (Snowpark Container Services) authentication now properly handles API keys
13+
and aligns with codebase patterns for server type detection and initialization.
14+
1015
## [1.27.1] - 2025-08-12
1116

1217
### Fixed

rsconnect/api.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,17 @@ def __init__(
241241

242242

243243
class SPCSConnectServer(AbstractRemoteServer):
244-
""" """
244+
"""
245+
A class to encapsulate the information needed to interact with an instance
246+
of Posit Connect deployed in Snowflake SPCS (Snowpark Container Services).
247+
248+
SPCS deployments use Snowflake OIDC authentication combined with Connect API keys.
249+
"""
245250

246251
def __init__(
247252
self,
248253
url: str,
254+
api_key: Optional[str],
249255
snowflake_connection_name: Optional[str],
250256
insecure: bool = False,
251257
ca_data: Optional[str | bytes] = None,
@@ -256,7 +262,7 @@ def __init__(
256262
self.ca_data = ca_data
257263
# for compatibility with RSConnectClient
258264
self.cookie_jar = CookieJar()
259-
self.api_key = None
265+
self.api_key = api_key
260266
self.bootstrap_jwt = None
261267

262268
def token_endpoint(self) -> str:
@@ -396,6 +402,8 @@ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: O
396402
if server.snowflake_connection_name and isinstance(server, SPCSConnectServer):
397403
token = server.exchange_token()
398404
self.snowflake_authorization(token)
405+
if server.api_key:
406+
self._headers["X-RSC-Authorization"] = server.api_key
399407

400408
def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
401409
return (
@@ -905,12 +913,12 @@ def setup_remote_server(
905913

906914
self.is_server_from_store = server_data.from_store
907915

908-
if api_key:
916+
if snowflake_connection_name:
909917
url = cast(str, url)
910-
self.remote_server = RSConnectServer(url, api_key, insecure, ca_data)
911-
elif snowflake_connection_name:
918+
self.remote_server = SPCSConnectServer(url, api_key, snowflake_connection_name, insecure, ca_data)
919+
elif api_key:
912920
url = cast(str, url)
913-
self.remote_server = SPCSConnectServer(url, snowflake_connection_name)
921+
self.remote_server = RSConnectServer(url, api_key, insecure, ca_data)
914922
elif token and secret:
915923
if url and ("rstudio.cloud" in url or "posit.cloud" in url):
916924
account_name = cast(str, account_name)
@@ -989,8 +997,9 @@ def validate_spcs_server(self):
989997
raise RSConnectException("remote_server must be a Connect server in SPCS")
990998

991999
url = self.remote_server.url
1000+
api_key = self.remote_server.api_key
9921001
snowflake_connection_name = self.remote_server.snowflake_connection_name
993-
server = SPCSConnectServer(url, snowflake_connection_name)
1002+
server = SPCSConnectServer(url, api_key, snowflake_connection_name)
9941003

9951004
with RSConnectClient(server) as client:
9961005
try:

rsconnect/main.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,11 +584,14 @@ def add(
584584

585585
if server and ("snowflakecomputing.app" in server or snowflake_connection_name):
586586

587-
real_server_spcs = api.SPCSConnectServer(server, snowflake_connection_name)
587+
server = cast(str, server)
588+
api_key = cast(str, api_key)
589+
590+
real_server_spcs = api.SPCSConnectServer(server, api_key, snowflake_connection_name)
588591

589592
_test_spcs_creds(real_server_spcs)
590593

591-
server_store.set(name, server, snowflake_connection_name=snowflake_connection_name)
594+
server_store.set(name, server, api_key=api_key, snowflake_connection_name=snowflake_connection_name)
592595
if old_server:
593596
click.echo('Updated {} credential "{}".'.format(real_server_spcs.remote_name, name))
594597
else:

rsconnect/metadata.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,10 @@ def set(
347347
"name": name,
348348
"url": url,
349349
}
350-
if api_key:
350+
if snowflake_connection_name:
351+
target_data = dict(snowflake_connection_name=snowflake_connection_name, api_key=api_key)
352+
elif api_key:
351353
target_data = dict(api_key=api_key, insecure=insecure, ca_cert=ca_data)
352-
elif snowflake_connection_name:
353-
target_data = dict(snowflake_connection_name=snowflake_connection_name)
354354
elif account_name:
355355
target_data = dict(account_name=account_name, token=token, secret=secret)
356356
else:

tests/test_api.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -513,37 +513,37 @@ def test_do_deploy_failure(self):
513513

514514
class SPCSConnectServerTestCase(TestCase):
515515
def test_init(self):
516-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
516+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
517517
assert server.url == "https://spcs.example.com"
518518
assert server.remote_name == "Posit Connect (SPCS)"
519519
assert server.snowflake_connection_name == "example_connection"
520-
assert server.api_key is None
520+
assert server.api_key == "test-api-key"
521521

522522
@patch("rsconnect.api.SPCSConnectServer.token_endpoint")
523523
def test_token_endpoint(self, mock_token_endpoint):
524-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
524+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
525525
mock_token_endpoint.return_value = "https://example.snowflakecomputing.com/"
526526
endpoint = server.token_endpoint()
527527
assert endpoint == "https://example.snowflakecomputing.com/"
528528

529529
@patch("rsconnect.api.get_parameters")
530530
def test_token_endpoint_with_account(self, mock_get_parameters):
531-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
531+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
532532
mock_get_parameters.return_value = {"account": "test_account"}
533533
endpoint = server.token_endpoint()
534534
assert endpoint == "https://test_account.snowflakecomputing.com/"
535535
mock_get_parameters.assert_called_once_with("example_connection")
536536

537537
@patch("rsconnect.api.get_parameters")
538538
def test_token_endpoint_with_none_params(self, mock_get_parameters):
539-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
539+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
540540
mock_get_parameters.return_value = None
541541
with pytest.raises(RSConnectException, match="No Snowflake connection found."):
542542
server.token_endpoint()
543543

544544
@patch("rsconnect.api.get_parameters")
545545
def test_fmt_payload(self, mock_get_parameters):
546-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
546+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
547547
mock_get_parameters.return_value = {
548548
"account": "test_account",
549549
"role": "test_role",
@@ -566,7 +566,7 @@ def test_fmt_payload(self, mock_get_parameters):
566566

567567
@patch("rsconnect.api.get_parameters")
568568
def test_fmt_payload_with_none_params(self, mock_get_parameters):
569-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
569+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
570570
mock_get_parameters.return_value = None
571571
with pytest.raises(RSConnectException, match="No Snowflake connection found."):
572572
server.fmt_payload()
@@ -575,7 +575,7 @@ def test_fmt_payload_with_none_params(self, mock_get_parameters):
575575
@patch("rsconnect.api.SPCSConnectServer.token_endpoint")
576576
@patch("rsconnect.api.SPCSConnectServer.fmt_payload")
577577
def test_exchange_token_success(self, mock_fmt_payload, mock_token_endpoint, mock_http_server):
578-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
578+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
579579

580580
# Mock the HTTP request
581581
mock_server_instance = mock_http_server.return_value
@@ -609,7 +609,7 @@ def test_exchange_token_success(self, mock_fmt_payload, mock_token_endpoint, moc
609609
@patch("rsconnect.api.SPCSConnectServer.token_endpoint")
610610
@patch("rsconnect.api.SPCSConnectServer.fmt_payload")
611611
def test_exchange_token_error_status(self, mock_fmt_payload, mock_token_endpoint, mock_http_server):
612-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
612+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
613613

614614
# Mock the HTTP request with error status
615615
mock_server_instance = mock_http_server.return_value
@@ -635,7 +635,7 @@ def test_exchange_token_error_status(self, mock_fmt_payload, mock_token_endpoint
635635
@patch("rsconnect.api.SPCSConnectServer.token_endpoint")
636636
@patch("rsconnect.api.SPCSConnectServer.fmt_payload")
637637
def test_exchange_token_empty_response(self, mock_fmt_payload, mock_token_endpoint, mock_http_server):
638-
server = SPCSConnectServer("https://spcs.example.com", "example_connection")
638+
server = SPCSConnectServer("https://spcs.example.com", "test-api-key", "example_connection")
639639

640640
# Mock the HTTP request with empty response body
641641
mock_server_instance = mock_http_server.return_value

tests/test_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def test_add(self):
7474

7575
self.assertEqual(
7676
self.server_store.get_by_name("qux"),
77-
dict(name="qux", url="https://example.snowflakecomputing.app", snowflake_connection_name="dev"),
77+
dict(name="qux", url="https://example.snowflakecomputing.app", snowflake_connection_name="dev", api_key=None),
7878
)
7979

8080
def test_remove_by_name(self):

0 commit comments

Comments
 (0)