Skip to content

Commit

Permalink
Add viewer-based credential support for Azure, Databricks & Snowflake (
Browse files Browse the repository at this point in the history
…#285)

This commit brings support for Posit Connect's "viewer-based
credentials" feature [0] to Azure, Databricks, and Snowflake chatbots.

Checks for viewer-based credentials are designed to fall back gracefully
to existing authentication methods. This is intended to allow users to
-- for example -- develop and test a Shiny app that uses Azure,
Databricks, or Snowflake credentials in desktop Positron/RStudio or
Posit Workbench and deploy it with no code changes to Connect.

Most of the actual work is outsourced to a new shared package,
`connectcreds` [1]. In this latest version, the API of that package has
been dramatically simplified, meaning the changes to `ellmer` are far
less invasive than in previous patches.

Unit tests are included. They make use of the mocking features of the
`connectcreds` package to emulate a Connect environment.

[0]: https://docs.posit.co/connect/user/oauth-integrations/
[1]: https://github.com/posit-dev/connectcreds/

Signed-off-by: Aaron Jacobs <[email protected]>
  • Loading branch information
atheriel authored Feb 3, 2025
1 parent 32f8497 commit 629166e
Show file tree
Hide file tree
Showing 15 changed files with 108 additions and 20 deletions.
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Imports:
Suggests:
base64enc,
bslib,
connectcreds,
curl (>= 6.0.1),
gitcreds,
knitr,
Expand Down
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
* `chat_bedrock()` now handles temporary IAM credentials better (#261,
@atheriel).

* `chat_azure()`, `chat_databricks()`, `chat_snowflake()`, and
`chat_cortex_analyst()` now detect viewer-based credentials when running on
Posit Connect (#285, @atheriel).

# ellmer 0.1.0

* New `chat_vllm()` to chat with models served by vLLM (#140).
Expand Down
28 changes: 18 additions & 10 deletions R/provider-azure.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ NULL
#' ## Authentication
#'
#' `chat_azure()` supports API keys and the `credentials` parameter, but it also
#' picks up on Azure service principals automatically when the
#' `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` environment
#' variables are set.
#' makes use of:
#'
#' Finally, in interactive sessions it will also attempt to use Microsoft Entra
#' ID authentication -- much like the Azure CLI -- if no API key has been
#' provided.
#' - Azure service principals (when the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`,
#' and `AZURE_CLIENT_SECRET` environment variables are set).
#' - Interactive Entra ID authentication, like the Azure CLI.
#' - Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds}
#' package.
#'
#' @param endpoint Azure OpenAI endpoint url with protocol and hostname, i.e.
#' `https://{your-resource-name}.openai.azure.com`. Defaults to using the
Expand Down Expand Up @@ -214,6 +214,16 @@ default_azure_credentials <- function(api_key = NULL, token = NULL) {
return(function() list(Authorization = paste("Bearer", token)))
}

azure_openai_scope <- "https://cognitiveservices.azure.com/.default"

# Detect viewer-based credentials from Posit Connect.
if (has_connect_viewer_token(scope = azure_openai_scope)) {
return(function() {
token <- connectcreds::connect_viewer_token(scope = azure_openai_scope)
list(Authorization = paste("Bearer", token$access_token))
})
}

# Detect Azure service principals.
tenant_id <- Sys.getenv("AZURE_TENANT_ID")
client_id <- Sys.getenv("AZURE_CLIENT_ID")
Expand All @@ -236,9 +246,7 @@ default_azure_credentials <- function(api_key = NULL, token = NULL) {
token <- oauth_token_cached(
client,
oauth_flow_client_credentials,
flow_params = list(
scope = "https://cognitiveservices.azure.com/.default"
),
flow_params = list(scope = azure_openai_scope),
# Don't use the cached token when testing.
reauth = is_testing()
)
Expand Down Expand Up @@ -267,7 +275,7 @@ default_azure_credentials <- function(api_key = NULL, token = NULL) {
oauth_flow_auth_code,
flow_params = list(
auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
scope = "https://cognitiveservices.azure.com/.default offline_access",
scope = paste(azure_openai_scope, "offline_access"),
redirect_uri = "http://localhost:8400",
auth_params = list(prompt = "select_account")
)
Expand Down
2 changes: 2 additions & 0 deletions R/provider-cortex.R
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ NULL
#' to one) environment variables.
#' - Posit Workbench-managed Snowflake credentials for the corresponding
#' `account`.
#' - Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds}
#' package.
#'
#' ## Known limitations
#'
Expand Down
10 changes: 10 additions & 0 deletions R/provider-databricks.R
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
#' - User account via OAuth (OAuth U2M)
#' - Authentication via the Databricks CLI
#' - Posit Workbench-managed credentials
#' - Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds}
#' package.
#'
#' ## Known limitations
#'
Expand Down Expand Up @@ -193,6 +195,14 @@ databricks_user_agent <- function() {
default_databricks_credentials <- function(workspace = databricks_workspace()) {
host <- gsub("https://|/$", "", workspace)

# Detect viewer-based credentials from Posit Connect.
if (has_connect_viewer_token(resource = workspace)) {
return(function() {
token <- connectcreds::connect_viewer_token(workspace)
list(Authorization = paste("Bearer", token$access_token))
})
}

# An explicit PAT takes precedence over everything else.
token <- Sys.getenv("DATABRICKS_TOKEN")
if (nchar(token)) {
Expand Down
14 changes: 14 additions & 0 deletions R/provider-snowflake.R
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ NULL
#' to one) environment variables.
#' - Posit Workbench-managed Snowflake credentials for the corresponding
#' `account`.
#' - Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds}
#' package.
#'
#' ## Known limitations
#' Note that Snowflake-hosted models do not support images, tool calling, or
Expand Down Expand Up @@ -191,6 +193,18 @@ snowflake_user_agent <- function() {
}

default_snowflake_credentials <- function(account = snowflake_account()) {
# Detect viewer-based credentials from Posit Connect.
url <- snowflake_url(account)
if (is_installed("connectcreds") && connectcreds::has_viewer_token(url)) {
return(function() {
token <- connectcreds::connect_viewer_token(url)
list(
Authorization = paste("Bearer", token$access_token),
`X-Snowflake-Authorization-Token-Type` = "OAUTH"
)
})
}

token <- Sys.getenv("SNOWFLAKE_TOKEN")
if (nchar(token) != 0) {
return(function() {
Expand Down
7 changes: 7 additions & 0 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,10 @@ credentials_cache <- function(key) {
clear = function() env_unbind(the$credentials_cache, key)
)
}

has_connect_viewer_token <- function(...) {
if (!is_installed("connectcreds")) {
return(FALSE)
}
connectcreds::has_viewer_token(...)
}
15 changes: 8 additions & 7 deletions man/chat_azure.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion man/chat_cortex.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion man/chat_cortex_analyst.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions man/chat_databricks.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion man/chat_snowflake.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions tests/testthat/test-provider-azure.R
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,11 @@ test_that("service principal authentication requests look correct", {
source <- default_azure_credentials()
expect_equal(source(), list(Authorization = "Bearer token"))
})

test_that("tokens can be requested from a Connect server", {
skip_if_not_installed("connectcreds")

connectcreds::local_mocked_connect_responses(token = "token")
credentials <- default_azure_credentials()
expect_equal(credentials(), list(Authorization = "Bearer token"))
})
12 changes: 12 additions & 0 deletions tests/testthat/test-provider-databricks.R
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,15 @@ test_that("the user agent respects SPARK_CONNECT_USER_AGENT when set", {
expect_match(databricks_user_agent(), "^testing r-ellmer")
)
})

test_that("tokens can be requested from a Connect server", {
skip_if_not_installed("connectcreds")

withr::local_envvar(
DATABRICKS_HOST = "https://example.cloud.databricks.com",
DATABRICKS_TOKEN = "token"
)
connectcreds::local_mocked_connect_responses(token = "token")
credentials <- default_databricks_credentials()
expect_equal(credentials(), list(Authorization = "Bearer token"))
})
15 changes: 15 additions & 0 deletions tests/testthat/test-provider-snowflake.R
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,18 @@ test_that("Snowflake key-pair credentials are detected correctly", {
)
)
})

test_that("tokens can be requested from a Connect server", {
skip_if_not_installed("connectcreds")

withr::local_envvar(SNOWFLAKE_ACCOUNT = "testorg-test_account")
connectcreds::local_mocked_connect_responses(token = "token")
credentials <- default_snowflake_credentials()
expect_identical(
credentials(),
list(
Authorization = "Bearer token",
`X-Snowflake-Authorization-Token-Type` = "OAUTH"
)
)
})

0 comments on commit 629166e

Please sign in to comment.