Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ 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
- 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
Expand Down
50 changes: 35 additions & 15 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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 <nickname> -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
Expand Down Expand Up @@ -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 <nickname> -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)
Expand Down Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions tests/test_connect_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading