Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ 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.

## [1.32.0] - 2026-10-16

- OAuth clients now register with Posit Connect as "Posit CLI", the name shown on
Expand Down
2 changes: 1 addition & 1 deletion docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3319,6 +3319,8 @@ class ConnectCloudAccount(TypedDict):
id: str
name: str
permissions: NotRequired[list[str]]
role: NotRequired[Optional[str]]
sso_enabled: NotRequired[bool]


class ConnectCloudAccountSearchResults(TypedDict):
Expand Down Expand Up @@ -3420,7 +3422,7 @@ def _refresh_user_token(self) -> dict[str, Any]:
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.

Expand All @@ -3447,8 +3449,8 @@ def _attempt_token_refresh(self) -> bool:
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
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:
Expand Down Expand Up @@ -3545,7 +3547,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
Expand Down Expand Up @@ -3650,6 +3652,21 @@ 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)
)
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
4 changes: 2 additions & 2 deletions rsconnect/connect_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"]
)
Expand Down
6 changes: 3 additions & 3 deletions rsconnect/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,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:
Expand All @@ -545,7 +545,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]]:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
49 changes: 36 additions & 13 deletions tests/test_connect_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -897,7 +897,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",
Expand Down Expand Up @@ -1015,7 +1015,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",
Expand All @@ -1026,7 +1026,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 <id> --client-secret <secret>",
Expand Down Expand Up @@ -1116,7 +1116,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)
Expand Down Expand Up @@ -1308,7 +1308,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,
[
Expand Down Expand Up @@ -1899,6 +1899,29 @@ 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_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")
)
_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("rsconnect add --connect-cloud -n <nickname> -A sso-publisher", 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("Client credentials", str(interactive_context.exception))


class TestConnectCloudAddVerifiesAccount(CliTestCase):
skip_account_check = False
Expand Down Expand Up @@ -3606,9 +3629,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;
Expand Down Expand Up @@ -3816,8 +3839,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())
Expand Down Expand Up @@ -4181,7 +4204,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"}
):
Expand Down
Loading