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

audit log first poc #60

Merged
merged 33 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7faabd3
audit log first poc
lwalejko Feb 15, 2024
94943df
Update documentation
lwalejko Feb 15, 2024
20a0628
saving audit logs and db and endpoint for geting study audit log
lwalejko Feb 20, 2024
3a5b72b
Merge branch '52-randomization-audit-trail' of https://github.com/tts…
lwalejko Feb 20, 2024
cf66240
Merge branch 'devel' into 52-randomization-audit-trail
lwalejko Feb 20, 2024
d4983af
Update documentation
lwalejko Feb 20, 2024
20546cd
implement audit log collection handling for all endpoints
lwalejko Feb 20, 2024
bec215c
remove unused functions
lwalejko Feb 20, 2024
720a69d
move error handling code to separate file
lwalejko Feb 20, 2024
db8901b
fix function scopes for coverage run
lwalejko Feb 20, 2024
ab8d6a6
simplify transaction handling for study creation
lwalejko Feb 20, 2024
32d6c8f
use check_study_exist method for checking if study exist
lwalejko Feb 20, 2024
8fa3508
remove redundant tryCatch block during saving randomized patient
lwalejko Feb 20, 2024
b02a8cd
ignore coverage for 2 lines that can be executed only in local develo…
lwalejko Feb 20, 2024
88d42a9
remove validation utils that are no longer used
lwalejko Feb 20, 2024
18a23d1
Merge branch '52-randomization-audit-trail' of https://github.com/tts…
lwalejko Feb 20, 2024
db140cc
Update documentation
lwalejko Feb 20, 2024
9c85adb
tests
lwalejko Feb 23, 2024
738f584
Update documentation
lwalejko Feb 23, 2024
d8cc29d
Merge branch 'devel' into 52-randomization-audit-trail
lwalejko Feb 23, 2024
2f52bf3
tests and upstream integration
lwalejko Feb 26, 2024
463f17f
Merge branch '52-randomization-audit-trail' of https://github.com/tts…
lwalejko Feb 26, 2024
976e86f
Add invalid JSON handling to error-handling.R
lwalejko Mar 1, 2024
fc1b497
simplyfy JSON rendering in audit trail get endpoint
lwalejko Mar 4, 2024
377e4ce
rename methods and simplify checks
lwalejko Mar 4, 2024
b82633a
Update documentation
lwalejko Mar 4, 2024
712b54f
Add IP address and user agent to audit log
lwalejko Mar 4, 2024
4a171c7
Update documentation
lwalejko Mar 4, 2024
604d419
Remove unnecessary JSON validation
lwalejko Mar 4, 2024
0c88cc6
test for malformed request
lwalejko Mar 4, 2024
bb61fa3
Merge branch '52-randomization-audit-trail' of https://github.com/tts…
lwalejko Mar 4, 2024
72c0a3a
Add IP address and user agent to audit logs api
lwalejko Mar 4, 2024
8c34747
disable code coverage for default_error_handler
lwalejko Mar 5, 2024
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
26 changes: 26 additions & 0 deletions R/api-audit-log.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
api_get_audit_log <- function(study_id, req, res) {
audit_log_disable_for_request(req)

if (!check_study_exist(study_id = study_id)) {
res$status <- 404
return(
list(error = "Study not found")
)
}

# Get audit trial
audit_trail <- dplyr::tbl(db_connection_pool, "audit_log") |>
dplyr::filter(study_id == !!study_id) |>
dplyr::collect()

audit_trail$request_body <- purrr::map(
audit_trail$request_body,
\(x) jsonlite::fromJSON(x)
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
)
audit_trail$response_body <- purrr::map(
audit_trail$response_body,
\(x) jsonlite::fromJSON(x)
)

return(audit_trail)
}
12 changes: 3 additions & 9 deletions R/api_create_study.R
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
api__minimization_pocock <- function(
# nolint: cyclocomp_linter.
identifier, name, method, arms, covariates, p, req, res) {
audit_log_event_type("study_create", req)

collection <- checkmate::makeAssertCollection()

checkmate::assert(
Expand Down Expand Up @@ -135,15 +137,7 @@ api__minimization_pocock <- function(
strata = strata
)

# Response ----------------------------------------------------------------

if (!is.null(r$error)) {
res$status <- 503
return(list(
error = "There was a problem saving created study to the database",
details = r$error
))
}
lwalejko marked this conversation as resolved.
Show resolved Hide resolved
audit_log_study_id(r$study$id, req)

response <- list(
study = r$study
Expand Down
10 changes: 3 additions & 7 deletions R/api_get_randomization_list.R
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
api_get_rand_list <- function(study_id, req, res) {
audit_log_event_type("get_rand_list", req)
db_connection_pool <- get("db_connection_pool")

study_id <- req$args$study_id

is_study <-
checkmate::test_true(
dplyr::tbl(db_connection_pool, "study") |>
dplyr::filter(id == study_id) |>
dplyr::collect() |>
nrow() > 0
)
is_study <- check_study_exist(study_id = study_id)

if (!is_study) {
res$status <- 404
return(list(
error = "Study not found"
))
}
audit_log_study_id(study_id, req)

patients <-
dplyr::tbl(db_connection_pool, "patient") |>
Expand Down
15 changes: 5 additions & 10 deletions R/api_get_study.R
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
api_get_study <- function(res, req) {
api_get_study <- function(req, res) {
audit_log_disable_for_request(req)
db_connection_pool <- get("db_connection_pool")

study_list <-
Expand All @@ -11,24 +12,18 @@ api_get_study <- function(res, req) {
}

api_get_study_records <- function(study_id, req, res) {
audit_log_event_type("get_study_record", req)
db_connection_pool <- get("db_connection_pool")

study_id <- req$args$study_id

is_study <-
checkmate::test_true(
dplyr::tbl(db_connection_pool, "study") |>
dplyr::filter(id == study_id) |>
dplyr::collect() |>
nrow() > 0
)

if (!is_study) {
if (!check_study_exist(study_id)) {
res$status <- 404
return(list(
error = "Study not found"
))
}
audit_log_study_id(study_id, req)

study <-
dplyr::tbl(db_connection_pool, "study") |>
Expand Down
35 changes: 11 additions & 24 deletions R/api_randomize.R
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,22 @@ parse_pocock_parameters <-
}

api__randomize_patient <- function(study_id, current_state, req, res) {
audit_log_event_type("randomize_patient", req)
collection <- checkmate::makeAssertCollection()

db_connection_pool <- get("db_connection_pool")

study_id <- req$args$study_id

is_study <-
checkmate::test_true(
dplyr::tbl(db_connection_pool, "study") |>
dplyr::filter(id == study_id) |>
dplyr::collect() |>
nrow() > 0
)

if (!is_study) {
if (!check_study_exist(study_id)) {
res$status <- 404
return(list(
error = "Study not found"
))
}

audit_log_study_id(study_id, req)

# Retrieve study details, especially the ones about randomization
method_randomization <-
dplyr::tbl(db_connection_pool, "study") |>
Expand Down Expand Up @@ -93,19 +88,11 @@ api__randomize_patient <- function(study_id, current_state, req, res) {
unbiased:::save_patient(study_id, arm$arm_id, used = TRUE) |>
select(-used)

if (!is.null(randomized_patient$error)) {
res$status <- 503
return(list(
error = "There was a problem saving randomized patient to the database",
details = randomized_patient$error
))
} else {
randomized_patient <-
randomized_patient |>
dplyr::mutate(arm_name = arm$name) |>
dplyr::rename(patient_id = id) |>
as.list()

return(randomized_patient)
}
randomized_patient <-
randomized_patient |>
dplyr::mutate(arm_name = arm$name) |>
dplyr::rename(patient_id = id) |>
as.list()

return(randomized_patient)
}
200 changes: 200 additions & 0 deletions R/audit-trail.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#' AuditLog Class
#'
#' This class is used internally to store audit logs for each request.
AuditLog <- R6::R6Class( # nolint: object_name_linter.
"AuditLog",
public = list(
initialize = function(request_method, endpoint_url, request_body) {
private$request_id <- uuid::UUIDgenerate()
lwalejko marked this conversation as resolved.
Show resolved Hide resolved
private$request_method <- request_method
private$endpoint_url <- endpoint_url
private$request_body <- request_body
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
},
disable = function() {
private$disabled <- TRUE
},
is_enabled = function() {
!private$disabled
},
set_request_body = function(request_body) {
if (typeof(request_body) == "list") {
request_body <- jsonlite::toJSON(request_body, auto_unbox = TRUE) |> as.character()
}
private$request_body <- request_body
},
set_response_body = function(response_body) {
checkmate::assert_false(
typeof(response_body) == "list"
)
private$response_body <- response_body
},
set_event_type = function(event_type) {
private$event_type <- event_type
},
set_study_id = function(study_id) {
private$study_id <- study_id
},
set_response_code = function(response_code) {
private$response_code <- response_code
},
validate_log = function() {
checkmate::assert(
!private$disabled
)
if (is.null(private$event_type)) {
if (private$response_code == 404) {
# "soft" validation failure for 404 errors
# it might be just invalid endpoint
# so we don't want to fail the request
return(FALSE)
} else {
stop("Event type not set for audit log. Please set the event type using `audit_log_event_type`")

Check warning on line 51 in R/audit-trail.R

View check run for this annotation

Codecov / codecov/patch

R/audit-trail.R#L51

Added line #L51 was not covered by tests
}
}
return(TRUE)
},
persist = function() {
checkmate::assert(
!private$disabled
)
db_conn <- pool::localCheckout(db_connection_pool)
values <- list(
private$request_id,
private$event_type,
private$study_id,
private$endpoint_url,
private$request_method,
private$request_body,
private$response_code,
private$response_body
)

values <- purrr::map(values, \(x) ifelse(is.null(x), NA, x))

DBI::dbGetQuery(
db_conn,
"INSERT INTO audit_log (
request_id,
event_type,
study_id,
endpoint_url,
request_method,
request_body,
response_code,
response_body
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
values
)
}
),
private = list(
disabled = FALSE,
request_id = NULL,
event_type = NULL,
study_id = NULL,
endpoint_url = NULL,
request_method = NULL,
response_code = NULL,
request_body = NULL,
response_body = NULL
)
)


#' Set up audit trail
#'
#' This function sets up an audit trail for a given process. It uses plumber's hooks to log
#' information before routing (preroute) and after serializing the response (postserialize).
#'
#' This function modifies the plumber router in place and returns the updated router.
#'
#' @param pr A plumber router for which the audit trail is to be set up.
#' @param endpoints A list of regex patterns for which the audit trail should be enabled.
#' @return Returns the updated plumber router with the audit trail hooks.
#' @examples
#' pr <- plumber::plumb("your-api-definition.R") |>
#' setup_audit_trail()
lwalejko marked this conversation as resolved.
Show resolved Hide resolved
setup_audit_trail <- function(pr, endpoints = list()) {
checkmate::assert(
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
is.list(endpoints),
all(sapply(endpoints, is.character)),
"endpoints must be a list of strings"
)
is_enabled_for_request <- function(req) {
any(sapply(endpoints, \(endpoint) grepl(endpoint, req$PATH_INFO)))
}

hooks <- list(
preroute = function(req, res) {
with_err_handler({
if (!is_enabled_for_request(req)) {
return()
}
audit_log <- AuditLog$new(
request_method = req$REQUEST_METHOD,
endpoint_url = req$PATH_INFO,
request_body = req$body
)
req$.internal.audit_log <- audit_log
})
},
postserialize = function(req, res) {
with_err_handler({
audit_log <- req$.internal.audit_log
if (is.null(audit_log) || !audit_log$is_enabled()) {
return()
}
audit_log$set_response_code(res$status)
audit_log$set_request_body(req$body)
audit_log$set_response_body(res$body)

log_valid <- audit_log$validate_log()
kamilsi marked this conversation as resolved.
Show resolved Hide resolved

if (log_valid) {
audit_log$persist()
}
})
}
)
pr |>
plumber::pr_hooks(hooks)
}

#' Set Audit Log Event Type
#'
#' This function sets the event type for an audit log. It retrieves the audit log from the request's
#' internal data, and then calls the audit log's set_event_type method with the provided event type.
#'
#' @param event_type The event type to be set for the audit log.
#' @param req The request object, which should contain an audit log in its internal data.
#' @return Returns nothing as it modifies the audit log in-place.
audit_log_event_type <- function(event_type, req) {
audit_log <- req$.internal.audit_log
if (!is.null(audit_log)) {
audit_log$set_event_type(event_type)
}
}

#' Set Audit Log Study ID
#'
#' This function sets the study ID for an audit log. It retrieves the audit log from the request's
#' internal data, and then calls the audit log's set_study_id method with the provided study ID.
#'
#' @param study_id The study ID to be set for the audit log.
#' @param req The request object, which should contain an audit log in its internal data.
#' @return Returns nothing as it modifies the audit log in-place.
audit_log_study_id <- function(study_id, req) {
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
assert(!is.null(study_id) || is.numeric(study_id), "Study ID must be a number")
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
audit_log <- req$.internal.audit_log
if (!is.null(audit_log)) {
audit_log$set_study_id(study_id)
}
}

audit_log_disable_for_request <- function(req) {
kamilsi marked this conversation as resolved.
Show resolved Hide resolved
audit_log <- req$.internal.audit_log
if (!is.null(audit_log)) {
audit_log$disable()
}
}
Loading
Loading