From 11cf808724a249012ca76a37a7fbe039deb64995 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Wed, 21 Oct 2026 16:04:24 -0700 Subject: [PATCH 1/2] fix: explain the SSO block in content migrate-to-connect-cloud --- docs/CHANGELOG.md | 3 ++- rsconnect/api.py | 50 ++++++++++++++++++++++++++----------- tests/test_connect_cloud.py | 19 ++++++++++++++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bca4247c..6a0887bd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Publishing to a Posit Connect Cloud account that requires single sign-on (SSO) with client credentials now explains that SSO blocks the publish, instead of - suggesting the publisher role. + suggesting the publisher role. `rsconnect content migrate-to-connect-cloud` + explains it the same way. ## [1.32.0] - 2026-10-16 diff --git a/rsconnect/api.py b/rsconnect/api.py index 34e90ae6..66b7a95f 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2678,6 +2678,9 @@ def migrate_to_connect_cloud( # validate_connect_cloud_server, so without this check a viewer role is # caught only by the deploy -- after the source record has been removed. if not service.can_publish_to(account): + sso_error = service.sso_blocked_error(account) + if sso_error: + raise sso_error raise RSConnectException( 'You have access to the Posit Connect Cloud account "%s" but do not have ' "permission to publish to it, so the next deploy from this directory would " @@ -3652,21 +3655,9 @@ def _publishable(self, account: ConnectCloudAccount) -> ConnectCloudAccount: "skipping the publish permission check." % name ) if not self._can_publish(account): - server = self._server - # The server does not grant content:create on SSO accounts to tokens without - # a recent SSO login, which client credentials never have. - if ( - server.client_id - and server.client_secret - and account.get("sso_enabled") - and account.get("role") in ("owner", "admin", "publisher") - ): - raise RSConnectException( - 'The Posit Connect Cloud account "%s" requires single sign-on (SSO). ' - "Client credentials cannot publish to accounts that require SSO. Log in interactively " - "with SSO instead: run `rsconnect add --connect-cloud -n -A %s` without " - "client credentials." % (name, name) - ) + sso_error = self._sso_blocked_error(self._server, account) + if sso_error: + raise sso_error raise RSConnectException( 'You have access to the Posit Connect Cloud account "%s" but do not have ' "permission to publish to it. Ask an account administrator for the publisher role." % name @@ -3695,6 +3686,29 @@ def _can_publish(account: ConnectCloudAccount) -> bool: return True return _CONNECT_CLOUD_PUBLISH_PERMISSION in permissions + @staticmethod + def _sso_blocked_error(server: ConnectCloudServer, account: ConnectCloudAccount) -> Optional[RSConnectException]: + """The error for client credentials that SSO keeps from publishing to this account, if that is + why the account cannot be published to. + + The server does not grant content:create on SSO accounts to tokens without + a recent SSO login, which client credentials never have. + """ + if not ( + server.client_id + and server.client_secret + and account.get("sso_enabled") + and account.get("role") in ("owner", "admin", "publisher") + ): + return None + name = account.get("name") + return RSConnectException( + 'The Posit Connect Cloud account "%s" requires single sign-on (SSO). ' + "Client credentials cannot publish to accounts that require SSO. Log in interactively " + "with SSO instead: run `rsconnect add --connect-cloud -n -A %s` without " + "client credentials." % (name, name) + ) + def get_content(self, content_id: str) -> ConnectCloudContent: response = cast(Union[ConnectCloudContent, HTTPResponse], self.get(f"/contents/{content_id}")) content = self._server.handle_bad_response(response) @@ -4117,6 +4131,12 @@ def can_publish_to(account: ConnectCloudAccount) -> bool: """ return ConnectCloudClient._can_publish(account) + def sso_blocked_error(self, account: ConnectCloudAccount) -> Optional[RSConnectException]: + """The error get_account_by_name raises when SSO keeps client credentials from + publishing to this account, or None when that is not why publishing is refused. + """ + return ConnectCloudClient._sso_blocked_error(self._server, account) + def content_url(self, content_id: str, account_id: Optional[str]) -> str: """Build the browsable URL for a content item. diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 0b1e9d60..8d140621 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -3144,6 +3144,25 @@ def test_an_account_without_publish_permission_is_refused(self): self.assertIsNotNone(saved.get(SHINYAPPS), "the source record must survive a refusal") self.assertIsNone(saved.get(MIGRATED_KEY)) + def test_client_credentials_on_an_sso_account_are_told_sso_blocks_them(self): + self._record(SHINYAPPS) + executor = self._executor() + executor.remote_server = ConnectCloudServer( + "acme", access_token="at", account_id="acct-1", client_id="cid", client_secret="csecret" + ) + executor.client.get_accounts.return_value = [ + {"id": "acct-1", "name": "acme", "role": "publisher", "sso_enabled": True, "permissions": []}, + ] + + with self.assertRaises(RSConnectException) as context: + executor.migrate_to_connect_cloud("c1") + + self.assertIn("requires single sign-on (SSO)", str(context.exception)) + self.assertNotIn("publisher role", str(context.exception)) + saved = self._store() + self.assertIsNotNone(saved.get(SHINYAPPS), "the source record must survive a refusal") + self.assertIsNone(saved.get(MIGRATED_KEY)) + def test_an_account_named_after_its_id_keeps_the_written_record(self): # The id-keyed and name-keyed record locations collide when the account's # name equals its id; removing the name-keyed one then deletes the record From 4ec8ee53bae06609d5b6488645975a0a0bb1a404 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Thu, 22 Oct 2026 09:49:36 -0700 Subject: [PATCH 2/2] docs: describe the SSO error change without naming a command --- docs/CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6a0887bd..e28b6344 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,10 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Publishing to a Posit Connect Cloud account that requires single sign-on (SSO) with - client credentials now explains that SSO blocks the publish, instead of - suggesting the publisher role. `rsconnect content migrate-to-connect-cloud` - explains it the same way. +- When client credentials can't publish to a Posit Connect Cloud account because the + account requires single sign-on (SSO), the error now says so, instead of + suggesting the publisher role. ## [1.32.0] - 2026-10-16