Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for viewer-based Databricks & Snowflake credentials #853

Merged
merged 1 commit into from
Oct 29, 2024
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
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Imports:
Suggests:
covr,
DBItest,
httr2,
knitr,
magrittr,
rmarkdown,
Expand Down
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@

* SQL Server: Fix issue related to writing when using SIMBA drivers (#816).

* `snowflake()` and `databricks()` now accept a `session` argument for passing
viewer-based OAuth credentials from Shiny sessions on Posit Connect
(@atheriel, #853).

# odbc 1.5.0

## Major changes
Expand Down
44 changes: 36 additions & 8 deletions R/driver-databricks.R
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ NULL
#' default name.
#' @param uid,pwd Manually specify a username and password for authentication.
#' Specifying these options will disable automated credential discovery.
#' @param session A Shiny session object, when using viewer-based credentials on
#' Posit Connect.
#' @param ... Further arguments passed on to [`dbConnect()`].
#'
#' @returns An `OdbcConnection` object with an active connection to a Databricks
Expand All @@ -42,6 +44,16 @@ NULL
#' odbc::databricks(),
#' httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj"
#' )
#'
#' # Use credentials from the viewer (when possible) in a Shiny app
#' # deployed to Posit Connect.
#' server <- function(input, output, session) {
#' conn <- DBI::dbConnect(
#' odbc::databricks(),
#' httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj",
#' session = session
#' )
#' }
#' }
#' @export
databricks <- function() {
Expand All @@ -64,6 +76,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver",
HTTPPath,
uid = NULL,
pwd = NULL,
session = NULL,
...) {
call <- caller_env()
# For backward compatibility with RStudio connection string
Expand All @@ -74,6 +87,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver",
check_string(driver, allow_null = TRUE, call = call)
check_string(uid, allow_null = TRUE, call = call)
check_string(pwd, allow_null = TRUE, call = call)
check_shiny_session(session, allow_null = TRUE, call = call)

args <- databricks_args(
httpPath = if (missing(httpPath)) HTTPPath else httpPath,
Expand All @@ -82,6 +96,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver",
driver = driver,
uid = uid,
pwd = pwd,
session = session,
...
)
inject(dbConnect(odbc(), !!!args))
Expand All @@ -94,6 +109,7 @@ databricks_args <- function(httpPath,
driver = NULL,
uid = NULL,
pwd = NULL,
session = NULL,
...) {
host <- databricks_host(workspace)

Expand All @@ -104,18 +120,21 @@ databricks_args <- function(httpPath,
useNativeQuery = useNativeQuery
)

auth <- databricks_auth_args(host, uid = uid, pwd = pwd)
auth <- databricks_auth_args(host, uid = uid, pwd = pwd, session = session)
all <- utils::modifyList(c(args, auth), list(...))

arg_names <- tolower(names(all))
if (!"authmech" %in% arg_names && !all(c("uid", "pwd") %in% arg_names)) {
abort(
c(
"x" = "Failed to detect ambient Databricks credentials.",
"i" = "Supply `uid` and `pwd` to authenticate manually."
),
call = quote(DBI::dbConnect())
msg <- c(
"Failed to detect ambient Databricks credentials.",
"i" = "Supply {.arg uid} and {.arg pwd} to authenticate manually."
)
if (running_on_connect()) {
msg <- c(
msg, "i" = "Or pass {.arg session} for viewer-based credentials."
)
}
cli::cli_abort(msg, call = quote(DBI::dbConnect()))
}

all
Expand Down Expand Up @@ -202,7 +221,16 @@ databricks_user_agent <- function() {
user_agent
}

databricks_auth_args <- function(host, uid = NULL, pwd = NULL) {
databricks_auth_args <- function(host, uid = NULL, pwd = NULL, session = NULL) {
# If a session is supplied, any viewer-based auth takes precedence.
if (!is.null(session)) {
check_installed("httr2", "for viewer-based authentication")
access_token <- connect_viewer_token(session, paste0("https://", host))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we emit something here? Seems like it would be useful to know when you've opted-in and it's not working. (And it wouldn't be too annoying to see that message when running locally.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified this so you see the following when running locally:

! Ignoring `sesssion` parameter.
ℹ Viewer-based credentials are only available when running on Connect.

if (!is.null(access_token)) {
return(list(authMech = 11, auth_flow = 0, auth_accesstoken = access_token))
}
}

if (!is.null(uid) && !is.null(pwd)) {
return(list(uid = uid, pwd = pwd, authMech = 3))
} else if (xor(is.null(uid), is.null(pwd))) {
Expand Down
38 changes: 32 additions & 6 deletions R/driver-snowflake.R
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ setMethod("odbcDataType", "Snowflake",
#' default.
#' @param uid,pwd Manually specify a username and password for authentication.
#' Specifying these options will disable ambient credential discovery.
#' @param session A Shiny session object, when using viewer-based credentials on
#' Posit Connect.
#' @param ... Further arguments passed on to [`dbConnect()`].
#'
#' @returns An `OdbcConnection` object with an active connection to a Snowflake
Expand All @@ -138,6 +140,12 @@ setMethod("odbcDataType", "Snowflake",
#' uid = "me",
#' pwd = rstudioapi::askForPassword()
#' )
#'
#' # Use credentials from the viewer (when possible) in a Shiny app
#' # deployed to Posit Connect.
#' server <- function(input, output, session) {
#' conn <- DBI::dbConnect(odbc::snowflake(), session = session)
#' }
#' }
#' @export
snowflake <- function() {
Expand All @@ -156,6 +164,7 @@ setMethod(
schema = NULL,
uid = NULL,
pwd = NULL,
session = NULL,
...) {
call <- caller_env()
check_string(account, call = call)
Expand All @@ -164,6 +173,7 @@ setMethod(
check_string(database, allow_null = TRUE, call = call)
check_string(uid, allow_null = TRUE, call = call)
check_string(pwd, allow_null = TRUE, call = call)
check_shiny_session(session, allow_null = TRUE, call = call)
args <- snowflake_args(
account = account,
driver = driver,
Expand All @@ -172,6 +182,7 @@ setMethod(
schema = schema,
uid = uid,
pwd = pwd,
session = session,
...
)
inject(dbConnect(odbc(), !!!args))
Expand Down Expand Up @@ -202,13 +213,16 @@ snowflake_args <- function(account = Sys.getenv("SNOWFLAKE_ACCOUNT"),

arg_names <- tolower(names(all))
if (!"authenticator" %in% arg_names && !all(c("uid", "pwd") %in% arg_names)) {
abort(
c(
"x" = "Failed to detect ambient Snowflake credentials.",
"i" = "Supply `uid` and `pwd` to authenticate manually."
),
call = quote(DBI::dbConnect())
msg <- c(
"Failed to detect ambient Snowflake credentials.",
"i" = "Supply {.arg uid} and {.arg pwd} to authenticate manually."
)
if (running_on_connect()) {
msg <- c(
msg, "i" = "Or pass {.arg session} for viewer-based credentials."
)
}
cli::cli_abort(msg, call = quote(DBI::dbConnect()))
}

all
Expand Down Expand Up @@ -270,7 +284,19 @@ snowflake_auth_args <- function(account,
uid = NULL,
pwd = NULL,
authenticator = NULL,
session = NULL,
...) {
# If a session is supplied, any viewer-based auth takes precedence.
if (!is.null(session)) {
check_installed("httr2", "for viewer-based authentication")
access_token <- connect_viewer_token(
session, paste0("https://", account, ".snowflakecomputing.com")
)
if (!is.null(access_token)) {
return(list(authenticator = "oauth", token = access_token))
}
}

if (!is.null(uid) &&
# allow for uid without pwd for externalbrowser auth (#817)
(!is.null(pwd) || identical(authenticator, "externalbrowser"))) {
Expand Down
76 changes: 76 additions & 0 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,79 @@ replace_or_append <- function(lines, pattern, replacement) {
}
lines
}

check_shiny_session <- function(x,
...,
allow_null = FALSE,
arg = caller_arg(x),
call = caller_env()) {
if (!missing(x)) {
if (inherits(x, "ShinySession")) {
return(invisible(NULL))
}
if (allow_null && is_null(x)) {
return(invisible(NULL))
}
}

stop_input_type(
x,
"a Shiny session object",
...,
allow_null = allow_null,
arg = arg,
call = call
)
}

# Request an OAuth access token for the given resource from Posit Connect. The
# OAuth token will belong to the user owning the given Shiny session.
connect_viewer_token <- function(session, resource) {
# Ensure we're running on Connect.
server_url <- Sys.getenv("CONNECT_SERVER")
api_key <- Sys.getenv("CONNECT_API_KEY")
if (!running_on_connect() || nchar(server_url) == 0 || nchar(api_key) == 0) {
cli::cli_inform(c(
"!" = "Ignoring {.arg sesssion} parameter.",
"i" = "Viewer-based credentials are only available when running on Connect."
))
return(NULL)
}

# Older versions or certain configurations of Connect might not supply a user
# session token.
token <- session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN
if (is.null(token)) {
cli::cli_abort(
"Viewer-based credentials are not supported by this version of Connect."
)
}

# See: https://docs.posit.co/connect/api/#post-/v1/oauth/integrations/credentials
req <- httr2::request(server_url)
req <- httr2::req_url_path_append(
req, "__api__/v1/oauth/integrations/credentials"
)
req <- httr2::req_headers(req,
Authorization = paste("Key", api_key), .redact = "Authorization"
hadley marked this conversation as resolved.
Show resolved Hide resolved
)
req <- httr2::req_body_form(
req,
grant_type = "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token_type = "urn:posit:connect:user-session-token",
subject_token = token,
resource = resource
)

# TODO: Do we need more precise error handling?
hadley marked this conversation as resolved.
Show resolved Hide resolved
req <- httr2::req_error(
req, body = function(resp) httr2::resp_body_json(resp)$error
)

resp <- httr2::resp_body_json(httr2::req_perform(req))
resp$access_token
}

running_on_connect <- function() {
Sys.getenv("RSTUDIO_PRODUCT") == "CONNECT"
}
14 changes: 14 additions & 0 deletions man/databricks.Rd

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

10 changes: 10 additions & 0 deletions man/snowflake.Rd

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

21 changes: 20 additions & 1 deletion tests/testthat/_snaps/driver-databricks.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
. <- databricks_args1()
Condition
Error in `DBI::dbConnect()`:
! x Failed to detect ambient Databricks credentials.
! Failed to detect ambient Databricks credentials.
i Supply `uid` and `pwd` to authenticate manually.

# must supply both uid and pwd
Expand Down Expand Up @@ -58,3 +58,22 @@
Error in `dbConnect()`:
! `httpPath` must be a single string or `NULL`, not the number 1.

# we mention viewer-based credentials have no effect locally

Code
ignored <- databricks_args(workspace = "workspace", httpPath = "path", driver = "driver",
uid = "uid", pwd = "pwd", session = list())
Message
! Ignoring `sesssion` parameter.
i Viewer-based credentials are only available when running on Connect.

# we hint viewer-based credentials on Connect

Code
databricks_args(workspace = "workspace", httpPath = "path", driver = "driver")
Condition
Error in `DBI::dbConnect()`:
! Failed to detect ambient Databricks credentials.
i Supply `uid` and `pwd` to authenticate manually.
i Or pass `session` for viewer-based credentials.

Loading
Loading