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
19 changes: 17 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Credential files written through the default file backend now use private
temporary files and atomic replacement, requiring write access to the parent
directory. Unchanged regular files have their permissions repaired. Default
protected saves replace a destination symlink even when its bytes are unchanged; hard-linked
copies no longer share subsequent updates.
- Authentication errors and verbose HTTP diagnostics omit provider-supplied
error descriptions, response reasons and redirect headers that could expose
credentials. OAuth response bodies and redirect destinations are also omitted
from verbose logs, along with all OAuth response header values.
Resumable Cloud account lookup uses the same protection. Encoded credential
query names are redacted, and cookie diagnostics omit names as well as values.
- Generated manifests and bundles exclude `.rsconnect-python` directories and
the configured CLI credential directory, including explicit extra files and
symlink aliases. Publishing from inside that directory is rejected, including
static notebook rendering before execution. Prepared
bundle archives remain unchanged.
- `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.
Resumable login requires POSIX (Linux or macOS). Pending state is validated
before saving and uses owner-only permissions. Resumable authentication omits
sensitive provider diagnostics. Connect Cloud finish retains the original OAuth client.
before saving and uses owner-only permissions. Connect Cloud finish retains the original OAuth client.
- Added `rsconnect preflight` to check Python and Node.js runtime availability on
self-hosted Posit Connect and Snowpark Container Services (SPCS), from POSIX systems. For first
publishes, it considers server installations marked publishable. Python
Expand Down
8 changes: 8 additions & 0 deletions docs/commands/deploy.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Generated bundles exclude `.rsconnect-python` directories and the configured
CLI credential directory, including symlink aliases and files supplied as
explicit extras. Keep application files outside that directory; publishing
from inside it is rejected.

Deploying an already prepared bundle uploads the archive as supplied. Check its
contents before deployment.

::: mkdocs-click
:module: rsconnect.main
:command: deploy
5 changes: 5 additions & 0 deletions docs/commands/write-manifest.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
Generated manifests exclude `.rsconnect-python` directories and the configured
CLI credential directory, including symlink aliases and files supplied as
explicit extras. Keep application files outside that directory; generating
a manifest from inside it is rejected.

::: mkdocs-click
:module: rsconnect.main
:command: write_manifest
38 changes: 16 additions & 22 deletions rsconnect/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool
safe_uri = _redacted_uri_for_log(response.full_uri)
if response.exception:
raise RSConnectException(
"Could not connect to %s - %s" % (_redacted_uri_for_log(self.url), response.exception),
"Could not connect to %s (%s)"
% (_redacted_uri_for_log(self.url), type(response.exception).__name__),
cause=response.exception,
)
# Sometimes an ISP will respond to an unknown server name by returning a friendly
Expand All @@ -188,12 +189,11 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool
raise RSConnectException(error, status=response.status)
if response.status is None or response.status < 200 or response.status > 299:
raise RSConnectException(
"Received an unexpected response from %s (calling %s): %s %s"
"Received an unexpected response from %s (calling %s): %s"
% (
self.remote_name,
safe_uri,
response.status,
response.reason,
),
status=response.status,
)
Expand All @@ -203,12 +203,11 @@ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool
# prior function call was not converted from a HTTPResponse to JSON. This
# should never happen, so raise an exception.
raise RSConnectException(
"Received an unexpected response from %s (calling %s): %s %s"
"Received an unexpected response from %s (calling %s): %s"
% (
self.remote_name,
safe_uri,
response.status,
response.reason,
)
)
return response
Expand Down Expand Up @@ -400,6 +399,7 @@ def fmt_payload(self):
def exchange_token(self) -> str:
try:
server = HTTPServer(url=self.token_endpoint())
server._suppress_oauth_response_logging = True
payload = self.fmt_payload()

response = server.request(
Expand All @@ -412,17 +412,17 @@ def exchange_token(self) -> str:
# since we don't want to pick up its json decoding assumptions
if response.exception is not None:
raise RSConnectException(
"Could not connect to %s - %s" % (self.token_endpoint(), response.exception),
"Could not connect to %s (%s)"
% (_redacted_uri_for_log(self.token_endpoint()), type(response.exception).__name__),
cause=response.exception,
)
if response.status is None or response.status < 200 or response.status > 299:
raise RSConnectException(
"Received an unexpected response from %s (calling %s): %s %s"
"Received an unexpected response from %s (calling %s): %s"
% (
self.url,
response.full_uri,
_redacted_uri_for_log(response.full_uri),
response.status,
response.reason,
)
)

Expand Down Expand Up @@ -598,10 +598,12 @@ def _attempt_token_refresh(self) -> bool:
store._set(entry_name, entry) # type: ignore[possibly-undefined]
logger.warning("OAuth client was re-registered; please run `rsconnect login` again.")
except Exception as exc:
logger.warning(f"OAuth client re-registration failed: {exc}. Please run `rsconnect login` again.")
logger.warning(
"OAuth client re-registration failed (%s). Please run `rsconnect login` again." % type(exc).__name__
)
return False
except Exception as exc:
logger.warning(f"OAuth token refresh failed: {exc}")
logger.warning("OAuth token refresh failed (%s)." % type(exc).__name__)
return False

new_access = token_response["access_token"]
Expand Down Expand Up @@ -3411,16 +3413,8 @@ def _refresh_user_token(self) -> dict[str, Any]:
request_options["request_deadline"] = self.request_deadline
if self._server.oauth_client_id is not None:
request_options["client_id_override"] = self._server.oauth_client_id
if self._suppress_oauth_response_logging:
request_options["suppress_response_logging"] = True
return connect_cloud.refresh(cast(str, self._server.refresh_token), self._server.environment, **request_options)

def _warn_refresh_failure(self, exception: Exception) -> None:
if self._suppress_oauth_response_logging:
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exception).__name__)
else:
logger.warning("Posit Connect Cloud token refresh failed: %s" % exception)

def _attempt_token_refresh(self) -> bool:
"""Mint a new access token and apply it to this client.

Expand Down Expand Up @@ -3448,7 +3442,7 @@ def _attempt_token_refresh(self) -> bool:
except InvalidClientError as exc:
if not service_account:
# This CLI's own OAuth client, not the user's credential.
self._warn_refresh_failure(exc)
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 "
Expand All @@ -3457,15 +3451,15 @@ def _attempt_token_refresh(self) -> bool:
) from exc
except InvalidGrantError as exc:
if service_account:
self._warn_refresh_failure(exc)
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
return False
self._persist_tokens(None, None)
raise RSConnectException(
"Your Posit Connect Cloud session has expired and could not be renewed. "
"Authenticate again with `%s`." % self._add_command()
) from exc
except RSConnectException as exc:
self._warn_refresh_failure(exc)
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
return False

access_token = tokens.get("access_token")
Expand Down
Loading
Loading