From d344b2bc13e8c6ed623ee064d0888d112b187b47 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Wed, 14 Oct 2026 17:31:52 -0700 Subject: [PATCH 1/4] fix: explain that service accounts cannot publish to SSO accounts --- docs/CHANGELOG.md | 3 +++ rsconnect/api.py | 15 +++++++++++++++ tests/test_connect_cloud.py | 22 ++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8eaca7f6..6d6d6693 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +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 + service account credentials now explains that SSO blocks the publish, instead of + suggesting the publisher role. - `login` and `add --connect-cloud` can start device-code authentication and return approval details as JSON. Finish a pending login by nickname with `--finish`. Use `--timeout` to bound the entire finish invocation, including account lookup. diff --git a/rsconnect/api.py b/rsconnect/api.py index 19c491d1..116a602d 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3317,6 +3317,8 @@ class ConnectCloudAccount(TypedDict): id: str name: str permissions: NotRequired[list[str]] + role: NotRequired[Optional[str]] + sso_enabled: NotRequired[bool] class ConnectCloudAccountSearchResults(TypedDict): @@ -3654,6 +3656,19 @@ 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 service account 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). ' + "Service account credentials cannot publish to accounts that require SSO." % name + ) 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 diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 2585f7de..f1d33da1 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1910,6 +1910,28 @@ def test_a_record_account_that_cannot_publish_is_reported_as_such(self): self.client.get_account_by_id_or_name("acct-2") self.assertIn("do not have permission to publish to it", str(context.exception)) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_a_service_account_on_an_sso_account_is_told_sso_blocks_it(self): + client = ConnectCloudClient( + ConnectCloudServer("acme", access_token="at", client_id="cid", client_secret="csecret") + ) + _register_accounts( + {"id": "1", "name": "sso-publisher", "role": "publisher", "sso_enabled": True, "permissions": []}, + {"id": "2", "name": "sso-viewer", "role": "viewer", "sso_enabled": True, "permissions": []}, + ) + with client: + with self.assertRaises(RSConnectException) as sso_context: + client.get_account_by_name("sso-publisher") + with self.assertRaises(RSConnectException) as viewer_context: + client.get_account_by_name("sso-viewer") + self.assertIn("requires single sign-on (SSO)", str(sso_context.exception)) + self.assertIn("do not have permission to publish to it", str(viewer_context.exception)) + + with self.client: + with self.assertRaises(RSConnectException) as interactive_context: + self.client.get_account_by_name("sso-publisher") + self.assertNotIn("Service account credentials", str(interactive_context.exception)) + class TestConnectCloudAddVerifiesAccount(CliTestCase): skip_account_check = False From 6ea66fb11d70cb27f73dc8b687cfa5aa58cb1289 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Thu, 15 Oct 2026 13:38:42 -0700 Subject: [PATCH 2/4] fix: say client credentials instead of service account in SSO error --- docs/CHANGELOG.md | 2 +- rsconnect/api.py | 4 ++-- tests/test_connect_cloud.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6d6d6693..9f0b1baf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,7 +8,7 @@ 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 - service account credentials now explains that SSO blocks the publish, instead of + client credentials now explains that SSO blocks the publish, instead of suggesting the publisher role. - `login` and `add --connect-cloud` can start device-code authentication and return approval details as JSON. Finish a pending login by nickname with `--finish`. diff --git a/rsconnect/api.py b/rsconnect/api.py index 116a602d..3f58c7b1 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3658,7 +3658,7 @@ def _publishable(self, account: ConnectCloudAccount) -> ConnectCloudAccount: 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 service account credentials never have. + # a recent SSO login, which client credentials never have. if ( server.client_id and server.client_secret @@ -3667,7 +3667,7 @@ def _publishable(self, account: ConnectCloudAccount) -> ConnectCloudAccount: ): raise RSConnectException( 'The Posit Connect Cloud account "%s" requires single sign-on (SSO). ' - "Service account credentials cannot publish to accounts that require SSO." % name + "Client credentials cannot publish to accounts that require SSO." % name ) raise RSConnectException( 'You have access to the Posit Connect Cloud account "%s" but do not have ' diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index f1d33da1..235950ed 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1911,7 +1911,7 @@ def test_a_record_account_that_cannot_publish_is_reported_as_such(self): self.assertIn("do not have permission to publish to it", str(context.exception)) @httpretty.activate(verbose=True, allow_net_connect=False) - def test_a_service_account_on_an_sso_account_is_told_sso_blocks_it(self): + def test_client_credentials_on_an_sso_account_are_told_sso_blocks_them(self): client = ConnectCloudClient( ConnectCloudServer("acme", access_token="at", client_id="cid", client_secret="csecret") ) @@ -1930,7 +1930,7 @@ def test_a_service_account_on_an_sso_account_is_told_sso_blocks_it(self): with self.client: with self.assertRaises(RSConnectException) as interactive_context: self.client.get_account_by_name("sso-publisher") - self.assertNotIn("Service account credentials", str(interactive_context.exception)) + self.assertNotIn("Client credentials", str(interactive_context.exception)) class TestConnectCloudAddVerifiesAccount(CliTestCase): From bfee0c1fe804dbcd6b4ee57579973df50a2a99e3 Mon Sep 17 00:00:00 2001 From: Sarah Choi Date: Thu, 15 Oct 2026 15:24:59 -0700 Subject: [PATCH 3/4] fix: suggest an interactive SSO login in the client credentials error --- rsconnect/api.py | 4 +++- tests/test_connect_cloud.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index 3f58c7b1..1989166b 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3667,7 +3667,9 @@ def _publishable(self, account: ConnectCloudAccount) -> ConnectCloudAccount: ): raise RSConnectException( 'The Posit Connect Cloud account "%s" requires single sign-on (SSO). ' - "Client credentials cannot publish to accounts that require SSO." % name + "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) ) raise RSConnectException( 'You have access to the Posit Connect Cloud account "%s" but do not have ' diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 235950ed..e0059d92 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1925,6 +1925,7 @@ def test_client_credentials_on_an_sso_account_are_told_sso_blocks_them(self): with self.assertRaises(RSConnectException) as viewer_context: client.get_account_by_name("sso-viewer") self.assertIn("requires single sign-on (SSO)", str(sso_context.exception)) + self.assertIn("rsconnect add --connect-cloud -n -A sso-publisher", str(sso_context.exception)) self.assertIn("do not have permission to publish to it", str(viewer_context.exception)) with self.client: From 7c3d3f400370b51d2aa337a1a7b47e09f96c8204 Mon Sep 17 00:00:00 2001 From: Sarah Choi <58533899+vrsarah@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:28:37 -0700 Subject: [PATCH 4/4] Call Connect Cloud client ID and secret client credentials (#869) * docs: call Connect Cloud client ID and secret client credentials * docs: drop changelog entry for the client credentials wording --- README.md | 2 +- docs/deploying.md | 2 +- rsconnect/api.py | 8 ++++---- rsconnect/connect_cloud.py | 4 ++-- rsconnect/main.py | 20 ++++++++++---------- rsconnect/metadata.py | 6 +++--- tests/test_agent_cli.py | 2 +- tests/test_connect_cloud.py | 26 +++++++++++++------------- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 2a4b9828..92653ddb 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Deploy your application: rsconnect deploy shiny app.py --name cloud --title "my shiny app" ``` -For non-interactive use such as CI, pass a service account credential with +For non-interactive use such as CI, pass client credentials with `--client-id` and `--client-secret`, or the `CONNECT_CLOUD_CLIENT_ID` and `CONNECT_CLOUD_CLIENT_SECRET` environment variables. diff --git a/docs/deploying.md b/docs/deploying.md index 97d4f8d5..7e5291ad 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -594,7 +594,7 @@ Stored information files are stored in a platform-specific directory: Remembered server information is stored in the `servers.json` file in that directory. OAuth credentials — the tokens from `rsconnect login` for Posit Connect, and the -tokens and service account client secret for Posit Connect Cloud — are stored in the +tokens and client secret for Posit Connect Cloud — are stored in the system keyring when one is available. On a machine without a usable keyring, such as a CI runner, they are kept in `servers.json` instead, which is written with owner-only permissions. diff --git a/rsconnect/api.py b/rsconnect/api.py index 1989166b..63fe52f4 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -3426,7 +3426,7 @@ def _warn_refresh_failure(self, exception: Exception) -> None: def _attempt_token_refresh(self) -> bool: """Mint a new access token and apply it to this client. - Uses the client credentials grant when a service account credential is + Uses the client credentials grant when client credentials are stored, and the refresh token otherwise. Returns whether a new token was obtained. @@ -3453,8 +3453,8 @@ def _attempt_token_refresh(self) -> bool: self._warn_refresh_failure(exc) return False raise RSConnectException( - "The Posit Connect Cloud service account credential was rejected — it has been revoked or " - "rotated. Create a new one at %s/identity/credentials, then save it with `%s`." + "The Posit Connect Cloud client credentials were rejected — they have been revoked or " + "rotated. Create new ones at %s/identity/credentials, then save them with `%s`." % (server.urls().auth, self._add_command(service_account=True)) ) from exc except InvalidGrantError as exc: @@ -3551,7 +3551,7 @@ def _persist_tokens(self, access_token: Optional[str], refresh_token: Optional[s # A refresh persists the new tokens and nothing else. Every other field # is taken from the saved entry alone — never from this run, which may # be publishing to a different account on the same login, or carrying - # service-account credentials from the environment that must not be + # client credentials from the environment that must not be # grafted onto an interactively created entry. `rsconnect add` is what # changes those. This is how the R client's withTokenRefreshRetry() # writes back too. ServerStore.set writes the file itself, and omits diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index dd58dafd..ed44f267 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -38,7 +38,7 @@ # deployments ENVIRONMENT_ENV_VAR = "CONNECT_CLOUD_ENVIRONMENT" # Overrides the OAuth client this CLI identifies itself as. Distinct from -# CONNECT_CLOUD_CLIENT_ID, which is a user's service account credential. +# CONNECT_CLOUD_CLIENT_ID, which is the client ID of a user's client credentials. OAUTH_CLIENT_ID_ENV_VAR = "CONNECT_CLOUD_OAUTH_CLIENT_ID" DEFAULT_ENVIRONMENT = "production" @@ -311,7 +311,7 @@ def store_tokens_in_keyring(url: str, nickname: str, access_token: Optional[str] def client_secret_from_keyring(url: str, nickname: str) -> tuple[bool, Optional[str]]: - """The stored service account client secret, and whether the keyring could be read. + """The stored client secret, and whether the keyring could be read. A read failure is not the same as no secret: overwriting one that may be there with an older copy from servers.json would break the credential. diff --git a/rsconnect/main.py b/rsconnect/main.py index 7cb64dc5..4c974fee 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -379,7 +379,7 @@ def _start_cloud_device_login(params: dict[str, Any]) -> dict[str, Any]: from .device_login import start_cloud_login if params.get("client_id") or params.get("client_secret"): - raise RSConnectException("--no-wait cannot be combined with service account credentials.") + raise RSConnectException("--no-wait cannot be combined with client credentials.") ctx = click.get_current_context() account = validation.effective_connect_cloud_account(ctx, params.get("account")) if not account: @@ -512,7 +512,7 @@ def connect_cloud_args(func: Callable[P, T]) -> Callable[P, T]: The target is selected either with `--server connect.posit.cloud`, mirroring how shinyapps.io is selected with `--server shinyapps.io`, or with the equivalent `--connect-cloud` shorthand. The remaining options supply the - service account credential used for non-interactive login. + client credentials used for non-interactive login. """ @click.option( @@ -525,13 +525,13 @@ def connect_cloud_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--client-id", envvar="CONNECT_CLOUD_CLIENT_ID", - help="The Posit Connect Cloud service account client ID, for non-interactive \ + help="The client ID of Posit Connect Cloud client credentials, for non-interactive \ authentication. (Also settable via CONNECT_CLOUD_CLIENT_ID environment variable.)", ) @click.option( "--client-secret", envvar="CONNECT_CLOUD_CLIENT_SECRET", - help="The Posit Connect Cloud service account client secret, for non-interactive \ + help="The client secret of Posit Connect Cloud client credentials, for non-interactive \ authentication. (Also settable via CONNECT_CLOUD_CLIENT_SECRET environment variable.)", ) @functools.wraps(func) @@ -866,8 +866,8 @@ def cli(future: bool): are needed. For Posit Connect Cloud, the server ('connect.posit.cloud') and account are needed. - Credentials come from an interactive browser login, or from a service account - client ID and secret for non-interactive use. + Credentials come from an interactive browser login, or from client + credentials (a client ID and secret) for non-interactive use. """ global future_enabled future_enabled = future @@ -925,7 +925,7 @@ def _connect_cloud_login( ) -> api.ConnectCloudServer: """Authenticate against Posit Connect Cloud and return the resulting server. - Uses the OAuth client credentials grant when a service account credential is + Uses the OAuth client credentials grant when client credentials are supplied, and the interactive device code flow otherwise. The server is built before logging in because its URL decides which environment's auth host to log in against: an explicitly typed staging API URL must not authenticate @@ -941,8 +941,8 @@ def _connect_cloud_login( if client_id or client_secret: if not (client_id and client_secret): raise RSConnectException( - "Both --client-id and --client-secret are required to authenticate with a " - "Posit Connect Cloud service account. Omit both to log in interactively." + "Both --client-id and --client-secret are required to authenticate with " + "Posit Connect Cloud client credentials. Omit both to log in interactively." ) tokens = connect_cloud_auth.login_client_credentials(client_id, client_secret, server.environment) else: @@ -1255,7 +1255,7 @@ def list_servers(verbose: int): if server.get("connect_cloud_account_name"): click.echo(" Posit Connect Cloud account: %s" % server["connect_cloud_account_name"]) if server.get("connect_cloud_client_id"): - click.echo(" Service account client ID: %s" % server["connect_cloud_client_id"]) + click.echo(" Client ID: %s" % server["connect_cloud_client_id"]) access, _, client_secret = connect_cloud_auth.credentials_from_keyring( server["url"], server["name"] ) diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 0905229f..30f61f6a 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -495,8 +495,8 @@ def _get_connect_cloud_server(self, url: str): binding: the login behind it can publish to every account its user has rights on, so -A/--account selects the publish target and only the nickname selects the credential. With several saved the choice is reported rather than guessed - at, because they may hold different credentials (an interactive login and a - service account, or two identities) which refresh differently. + at, because they may hold different credentials (an interactive login and + client credentials, or two identities) which refresh differently. """ candidates = self._connect_cloud_servers(url) if not candidates: @@ -512,7 +512,7 @@ def _get_connect_cloud_server(self, url: str): raise RSConnectException( "Several Posit Connect Cloud credentials are saved: %s. Use -n/--name to choose one, adding " "-A/--account to publish to a different account, or pass --client-id and --client-secret to " - "use a service account credential directly." % saved + "use client credentials directly." % saved ) def _connect_cloud_secrets(self, entry: ServerDataDict) -> tuple[Optional[str], Optional[str], Optional[str]]: diff --git a/tests/test_agent_cli.py b/tests/test_agent_cli.py index 1ed9e3d6..1d345edf 100644 --- a/tests/test_agent_cli.py +++ b/tests/test_agent_cli.py @@ -151,7 +151,7 @@ def test_finish_selected_login_and_pending_json(command, kind): (["login", "-n", "target", "--finish", "--timeout", "-1"], "Invalid value"), (["add", "-n", "target", "--no-wait"], "require --connect-cloud"), (["add", "--connect-cloud", "--no-wait"], "requires --account"), - (["add", "--connect-cloud", "-A", "team", "--client-id", "id", "--no-wait"], "service account"), + (["add", "--connect-cloud", "-A", "team", "--client-id", "id", "--no-wait"], "client credentials"), (["add", "--connect-cloud", "-A", "team", "-s", "https://other.example", "--no-wait"], "cannot be combined"), (["add", "--connect-cloud", "-A", "team", "--insecure", "--no-wait"], "Posit Connect options"), (["add", "--connect-cloud", "-A", "team", "--api-key", "synthetic", "--no-wait"], "Posit Connect options"), diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index e0059d92..c13b191c 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -95,10 +95,10 @@ def test_oauth_client_id_env_var_overrides(self): with mock.patch.dict(os.environ, {connect_cloud.OAUTH_CLIENT_ID_ENV_VAR: "custom-client"}, clear=True): self.assertEqual(connect_cloud.client_id("production"), "custom-client") - def test_oauth_client_id_var_is_distinct_from_service_account_var(self): - # CONNECT_CLOUD_CLIENT_ID is a user's service account credential, passed + def test_oauth_client_id_var_is_distinct_from_client_credentials_var(self): + # CONNECT_CLOUD_CLIENT_ID is the client ID of a user's client credentials, passed # with --client-id. It must not change which OAuth client the CLI is. - with mock.patch.dict(os.environ, {"CONNECT_CLOUD_CLIENT_ID": "service-account"}, clear=True): + with mock.patch.dict(os.environ, {"CONNECT_CLOUD_CLIENT_ID": "user-client-id"}, clear=True): self.assertEqual(connect_cloud.client_id("production"), "rsconnect-python") @@ -909,7 +909,7 @@ def test_a_refresh_does_not_graft_environment_credentials_onto_the_entry(self): # An interactively created entry has no client credentials. If the shell # happens to export CONNECT_CLOUD_CLIENT_ID/SECRET, a refresh may *use* # them, but persisting them would silently convert the entry into a - # service-account credential. + # client credentials entry. self._save_entry(connect_cloud_refresh_token="rt") server = ConnectCloudServer( "acme", @@ -1027,7 +1027,7 @@ def test_the_reauthentication_command_quotes_values_that_need_it(self): self.assertIn("-n 'my cloud' -A acme", exception.message) @httpretty.activate(verbose=True, allow_net_connect=False) - def test_a_rejected_service_account_secret_is_reported_and_the_entry_is_left_alone(self): + def test_rejected_client_credentials_are_reported_and_the_entry_is_left_alone(self): self._save_entry( connect_cloud_client_id="cid", connect_cloud_client_secret="csecret", @@ -1038,7 +1038,7 @@ def test_a_rejected_service_account_secret_is_reported_and_the_entry_is_left_alo ) exception = self._get_user_with_refresh_failure(server, "login_client_credentials", InvalidClientError()) - self.assertIn("service account credential was rejected", exception.message) + self.assertIn("client credentials were rejected", exception.message) self.assertIn("https://login.posit.cloud/identity/credentials", exception.message) self.assertIn( "rsconnect add --connect-cloud -n cloud -A acme --client-id --client-secret ", @@ -1127,7 +1127,7 @@ def test_a_seekable_stream_is_rewound(self): self.assertEqual(self._retry_bodies(io.BytesIO(b"payload")), [b"payload", b"payload"]) def test_a_stream_is_left_alone_when_there_is_nothing_to_refresh_with(self): - # No refresh token and no service account credential: nothing can be minted, + # No refresh token and no client credentials: nothing can be minted, # so the request is sent once and its body is neither buffered nor rewound. stream = io.BytesIO(b"payload") seen = self._attempt_bodies(stream, server=ConnectCloudServer("acme", access_token="at"), read=False) @@ -1319,7 +1319,7 @@ def test_add_rejects_connect_api_key(self): self.assertEqual(result.exit_code, 1, result.output) self.assertIn("may not be passed", result.output) - def test_add_rejects_half_a_service_account_credential(self): + def test_add_rejects_half_of_the_client_credentials(self): result = self.runner.invoke( cli, [ @@ -3640,9 +3640,9 @@ def test_list_shows_connect_cloud_details(self): self.assertEqual(result.exit_code, 0, result.output) self.assertIn("Posit Connect Cloud account: acme", result.output) - self.assertIn("Service account client ID: cid", result.output) + self.assertIn("Client ID: cid", result.output) self.assertIn("Credentials are saved", result.output) - self.assertNotIn("at", result.output.split("Credentials are saved")[0].split("client ID: cid")[1]) + self.assertNotIn("at", result.output.split("Credentials are saved")[0].split("Client ID: cid")[1]) def test_manifest_deploy_marks_a_defaulted_title(self): # deploy manifest computes a default title before building the executor; @@ -3850,8 +3850,8 @@ def test_flag_selects_connect_cloud_in_the_executor(self): class TestConnectCloudSameAccountAmbiguity(unittest.TestCase): - """Several credentials may share a default account (an interactive login and a - service account, say), so the account cannot pick one of them.""" + """Several credentials may share a default account (an interactive login and + client credentials, say), so the account cannot pick one of them.""" def _store(self): store = ServerStore(base_dir=tempfile.mkdtemp()) @@ -4215,7 +4215,7 @@ def test_add_stores_the_tokens_in_the_keyring_and_not_in_the_file(self): self.assertNotIn("connect_cloud_refresh_token", entry) self.assertEqual(entry["connect_cloud_account_name"], "acme") - def test_add_stores_a_service_account_secret_in_the_keyring(self): + def test_add_stores_a_client_secret_in_the_keyring(self): with mock.patch( "rsconnect.connect_cloud.request_client_credentials_token", return_value={"access_token": "at"} ):