cjerzak commited on
Commit
bc43d06
·
1 Parent(s): 1dd1e45

add files

Browse files
Files changed (4) hide show
  1. R/asa_api_helpers.R +117 -13
  2. R/plumber.R +34 -14
  3. README.md +3 -0
  4. Tests/api_contract_smoke.R +84 -2
R/asa_api_helpers.R CHANGED
@@ -300,11 +300,74 @@ asa_api_secret_env_value <- function(name) {
300
  trimws(Sys.getenv(asa_api_scalar_chr(name, default = ""), unset = ""))
301
  }
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  asa_api_missing_auth_env_vars <- function() {
304
  required <- c("ASA_API_BEARER_TOKEN", "GUI_PASSWORD")
305
  required[!nzchar(vapply(required, asa_api_secret_env_value, character(1)))]
306
  }
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  asa_api_refresh_auth_cache <- function(force = FALSE) {
309
  cached_api_hash <- .asa_api_auth_cache$api_bearer_token_hash %||% ""
310
  cached_gui_hash <- .asa_api_auth_cache$gui_password_hash %||% ""
@@ -336,21 +399,49 @@ asa_api_refresh_auth_cache <- function(force = FALSE) {
336
  invisible(TRUE)
337
  }
338
 
339
- asa_api_verify_secret <- function(candidate, cache_key) {
340
  supplied <- asa_api_scalar_chr(candidate, default = "")
341
- if (!nzchar(supplied)) {
342
- return(FALSE)
343
- }
344
 
345
- tryCatch(
346
  {
347
  asa_api_refresh_auth_cache()
348
  stored_hash <- .asa_api_auth_cache[[asa_api_scalar_chr(cache_key, default = "")]] %||% ""
349
- is.character(stored_hash) &&
350
- nzchar(stored_hash) &&
351
- isTRUE(sodium::password_verify(stored_hash, supplied))
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  },
353
- error = function(e) FALSE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  )
355
  }
356
 
@@ -368,15 +459,28 @@ asa_api_extract_bearer_token <- function(req) {
368
  ""
369
  }
370
 
371
- asa_api_has_required_bearer_token <- function(req) {
372
- asa_api_verify_secret(
373
  asa_api_extract_bearer_token(req),
374
- "api_bearer_token_hash"
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  )
376
  }
377
 
378
  asa_api_has_required_gui_password <- function(password) {
379
- asa_api_verify_secret(password, "gui_password_hash")
380
  }
381
 
382
  asa_api_require_prompt <- function(payload) {
 
300
  trimws(Sys.getenv(asa_api_scalar_chr(name, default = ""), unset = ""))
301
  }
302
 
303
+ asa_api_error_fields <- function(message, error_code = NULL, details = NULL) {
304
+ payload <- list(
305
+ status = "error",
306
+ error = asa_api_scalar_chr(message, default = "Request failed.")
307
+ )
308
+
309
+ code <- asa_api_scalar_chr(error_code, default = "")
310
+ if (nzchar(code)) {
311
+ payload$error_code <- code
312
+ }
313
+
314
+ if (is.list(details) && length(details)) {
315
+ payload$details <- details
316
+ }
317
+
318
+ payload
319
+ }
320
+
321
  asa_api_missing_auth_env_vars <- function() {
322
  required <- c("ASA_API_BEARER_TOKEN", "GUI_PASSWORD")
323
  required[!nzchar(vapply(required, asa_api_secret_env_value, character(1)))]
324
  }
325
 
326
+ asa_api_extract_missing_auth_env_vars <- function(message = NULL) {
327
+ text <- asa_api_scalar_chr(message, default = "")
328
+ matches <- regmatches(
329
+ text,
330
+ gregexpr("`[^`]+`", text, perl = TRUE)
331
+ )[[1]]
332
+ parsed <- if (length(matches) && !identical(matches, character(0))) {
333
+ gsub("^`|`$", "", matches)
334
+ } else {
335
+ character(0)
336
+ }
337
+
338
+ sort(unique(c(parsed, asa_api_missing_auth_env_vars())))
339
+ }
340
+
341
+ asa_api_is_auth_config_error <- function(message = NULL) {
342
+ grepl(
343
+ "^Missing required authentication environment variable\\(s\\):",
344
+ asa_api_scalar_chr(message, default = "")
345
+ )
346
+ }
347
+
348
+ asa_api_boot_failure <- function(boot_error = NULL) {
349
+ text <- trimws(asa_api_scalar_chr(boot_error, default = ""))
350
+ if (!nzchar(text)) {
351
+ return(NULL)
352
+ }
353
+
354
+ if (isTRUE(asa_api_is_auth_config_error(text))) {
355
+ return(list(
356
+ status_code = 503L,
357
+ message = "Service unavailable: authentication is not configured.",
358
+ error_code = "auth_config_missing",
359
+ details = list(
360
+ missing_env_vars = asa_api_extract_missing_auth_env_vars(text)
361
+ )
362
+ ))
363
+ }
364
+
365
+ list(
366
+ status_code = 503L,
367
+ message = "Service unavailable."
368
+ )
369
+ }
370
+
371
  asa_api_refresh_auth_cache <- function(force = FALSE) {
372
  cached_api_hash <- .asa_api_auth_cache$api_bearer_token_hash %||% ""
373
  cached_gui_hash <- .asa_api_auth_cache$gui_password_hash %||% ""
 
399
  invisible(TRUE)
400
  }
401
 
402
+ asa_api_auth_check_secret <- function(candidate, cache_key, auth_target) {
403
  supplied <- asa_api_scalar_chr(candidate, default = "")
404
+ refresh_error <- NULL
 
 
405
 
406
+ auth_result <- tryCatch(
407
  {
408
  asa_api_refresh_auth_cache()
409
  stored_hash <- .asa_api_auth_cache[[asa_api_scalar_chr(cache_key, default = "")]] %||% ""
410
+ if (nzchar(supplied) &&
411
+ is.character(stored_hash) &&
412
+ nzchar(stored_hash) &&
413
+ isTRUE(sodium::password_verify(stored_hash, supplied))) {
414
+ return(list(ok = TRUE))
415
+ }
416
+
417
+ list(
418
+ ok = FALSE,
419
+ status_code = 401L,
420
+ message = "Unauthorized: provided credential did not match the configured value.",
421
+ error_code = "credential_mismatch",
422
+ details = list(
423
+ auth_target = asa_api_scalar_chr(auth_target, default = "")
424
+ )
425
+ )
426
  },
427
+ error = function(e) {
428
+ refresh_error <<- conditionMessage(e)
429
+ NULL
430
+ }
431
+ )
432
+ if (!is.null(auth_result)) {
433
+ return(auth_result)
434
+ }
435
+
436
+ boot_failure <- asa_api_boot_failure(refresh_error)
437
+ if (!is.null(boot_failure)) {
438
+ return(c(list(ok = FALSE), boot_failure))
439
+ }
440
+
441
+ list(
442
+ ok = FALSE,
443
+ status_code = 503L,
444
+ message = "Service unavailable."
445
  )
446
  }
447
 
 
459
  ""
460
  }
461
 
462
+ asa_api_check_bearer_token <- function(req) {
463
+ asa_api_auth_check_secret(
464
  asa_api_extract_bearer_token(req),
465
+ "api_bearer_token_hash",
466
+ "api_bearer_token"
467
+ )
468
+ }
469
+
470
+ asa_api_has_required_bearer_token <- function(req) {
471
+ isTRUE(asa_api_check_bearer_token(req)$ok)
472
+ }
473
+
474
+ asa_api_check_gui_password <- function(password) {
475
+ asa_api_auth_check_secret(
476
+ password,
477
+ "gui_password_hash",
478
+ "gui_password"
479
  )
480
  }
481
 
482
  asa_api_has_required_gui_password <- function(password) {
483
+ isTRUE(asa_api_check_gui_password(password)$ok)
484
  }
485
 
486
  asa_api_require_prompt <- function(payload) {
R/plumber.R CHANGED
@@ -39,9 +39,23 @@ tryCatch(
39
  }
40
  )
41
 
42
- asa_api_error_payload <- function(res, status, message) {
43
  res$status <- as.integer(status)
44
- list(status = "error", error = asa_api_scalar_chr(message, default = "Request failed."))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  asa_api_error_status <- function(message) {
@@ -76,12 +90,14 @@ function(req, res) {
76
  return(plumber::forward())
77
  }
78
 
79
- if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
80
- return(asa_api_error_payload(res, 503L, "Service unavailable."))
 
81
  }
82
 
83
- if (!asa_api_has_required_bearer_token(req)) {
84
- return(asa_api_error_payload(res, 401L, "Unauthorized."))
 
85
  }
86
 
87
  plumber::forward()
@@ -108,8 +124,9 @@ function() {
108
  #* @post /v1/run
109
  #* @serializer unboxedJSON
110
  function(req, res) {
111
- if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
112
- return(asa_api_error_payload(res, 503L, "Service unavailable."))
 
113
  }
114
 
115
  parse_error <- NULL
@@ -137,8 +154,9 @@ function(req, res) {
137
  #* @post /v1/batch
138
  #* @serializer unboxedJSON
139
  function(req, res) {
140
- if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
141
- return(asa_api_error_payload(res, 503L, "Service unavailable."))
 
142
  }
143
 
144
  parse_error <- NULL
@@ -166,8 +184,9 @@ function(req, res) {
166
  #* @post /gui/query
167
  #* @serializer unboxedJSON
168
  function(req, res) {
169
- if (is.character(.asa_api_boot_error) && nzchar(trimws(.asa_api_boot_error))) {
170
- return(asa_api_error_payload(res, 503L, "Service unavailable."))
 
171
  }
172
 
173
  parse_error <- NULL
@@ -182,8 +201,9 @@ function(req, res) {
182
  return(asa_api_error_payload(res, 400L, parse_error))
183
  }
184
 
185
- if (!asa_api_has_required_gui_password(payload$password)) {
186
- return(asa_api_error_payload(res, 401L, "Unauthorized."))
 
187
  }
188
 
189
  payload$include_raw_output <- FALSE
 
39
  }
40
  )
41
 
42
+ asa_api_error_payload <- function(res, status, message, error_code = NULL, details = NULL) {
43
  res$status <- as.integer(status)
44
+ asa_api_error_fields(
45
+ message,
46
+ error_code = error_code,
47
+ details = details
48
+ )
49
+ }
50
+
51
+ asa_api_failure_payload <- function(res, failure) {
52
+ asa_api_error_payload(
53
+ res,
54
+ failure$status_code %||% 500L,
55
+ failure$message %||% "Request failed.",
56
+ error_code = failure$error_code %||% NULL,
57
+ details = failure$details %||% NULL
58
+ )
59
  }
60
 
61
  asa_api_error_status <- function(message) {
 
90
  return(plumber::forward())
91
  }
92
 
93
+ boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
94
+ if (!is.null(boot_failure)) {
95
+ return(asa_api_failure_payload(res, boot_failure))
96
  }
97
 
98
+ auth_check <- asa_api_check_bearer_token(req)
99
+ if (!isTRUE(auth_check$ok)) {
100
+ return(asa_api_failure_payload(res, auth_check))
101
  }
102
 
103
  plumber::forward()
 
124
  #* @post /v1/run
125
  #* @serializer unboxedJSON
126
  function(req, res) {
127
+ boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
128
+ if (!is.null(boot_failure)) {
129
+ return(asa_api_failure_payload(res, boot_failure))
130
  }
131
 
132
  parse_error <- NULL
 
154
  #* @post /v1/batch
155
  #* @serializer unboxedJSON
156
  function(req, res) {
157
+ boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
158
+ if (!is.null(boot_failure)) {
159
+ return(asa_api_failure_payload(res, boot_failure))
160
  }
161
 
162
  parse_error <- NULL
 
184
  #* @post /gui/query
185
  #* @serializer unboxedJSON
186
  function(req, res) {
187
+ boot_failure <- asa_api_boot_failure(.asa_api_boot_error)
188
+ if (!is.null(boot_failure)) {
189
+ return(asa_api_failure_payload(res, boot_failure))
190
  }
191
 
192
  parse_error <- NULL
 
201
  return(asa_api_error_payload(res, 400L, parse_error))
202
  }
203
 
204
+ auth_check <- asa_api_check_gui_password(payload$password)
205
+ if (!isTRUE(auth_check$ok)) {
206
+ return(asa_api_failure_payload(res, auth_check))
207
  }
208
 
209
  payload$include_raw_output <- FALSE
README.md CHANGED
@@ -33,6 +33,9 @@ It uses:
33
  - GUI auth is password-based:
34
  - `/gui/query` verifies the submitted password against an in-memory hash derived from `GUI_PASSWORD`
35
  - The service fails closed if either auth secret env var is missing or blank.
 
 
 
36
 
37
  ## Required Environment Variables
38
 
 
33
  - GUI auth is password-based:
34
  - `/gui/query` verifies the submitted password against an in-memory hash derived from `GUI_PASSWORD`
35
  - The service fails closed if either auth secret env var is missing or blank.
36
+ - Auth errors are diagnostic but safe:
37
+ - missing auth env vars return `503` with `error_code: "auth_config_missing"` and `details.missing_env_vars`
38
+ - wrong GUI/API credentials return `401` with `error_code: "credential_mismatch"` and `details.auth_target`
39
 
40
  ## Required Environment Variables
41
 
Tests/api_contract_smoke.R CHANGED
@@ -170,6 +170,38 @@ assert_true(
170
  identical(asa_api_missing_auth_env_vars(), character(0)),
171
  "Auth bootstrap should require explicit bearer-token and GUI-password env vars."
172
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  assert_true(
174
  is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
175
  nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
@@ -218,9 +250,35 @@ assert_true(
218
  isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
219
  "GUI auth should accept the configured password."
220
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  assert_true(
222
- !isTRUE(asa_api_has_required_gui_password("wrong-password")),
223
- "GUI auth should reject the wrong password."
 
 
 
224
  )
225
 
226
  asa_api_clear_auth_cache()
@@ -229,6 +287,14 @@ expect_error_contains(
229
  asa_api_refresh_auth_cache(force = TRUE),
230
  "ASA_API_BEARER_TOKEN"
231
  )
 
 
 
 
 
 
 
 
232
  do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
233
 
234
  asa_api_clear_auth_cache()
@@ -240,6 +306,22 @@ expect_error_contains(
240
  do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
241
  asa_api_refresh_auth_cache(force = TRUE)
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  health_payload <- asa_api_health_payload()
244
  assert_true(
245
  identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),
 
170
  identical(asa_api_missing_auth_env_vars(), character(0)),
171
  "Auth bootstrap should require explicit bearer-token and GUI-password env vars."
172
  )
173
+ auth_config_boot_failure <- asa_api_boot_failure(
174
+ "Missing required authentication environment variable(s): `GUI_PASSWORD`, `ASA_API_BEARER_TOKEN`."
175
+ )
176
+ assert_true(
177
+ identical(auth_config_boot_failure$status_code, 503L) &&
178
+ identical(auth_config_boot_failure$error_code, "auth_config_missing") &&
179
+ identical(
180
+ sort(auth_config_boot_failure$details$missing_env_vars),
181
+ sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
182
+ ),
183
+ "Boot failures caused by missing auth env vars should expose a structured safe error."
184
+ )
185
+ generic_boot_failure <- asa_api_boot_failure("Package `asa` is not installed in this environment.")
186
+ assert_true(
187
+ identical(generic_boot_failure$status_code, 503L) &&
188
+ identical(generic_boot_failure$message, "Service unavailable.") &&
189
+ is.null(generic_boot_failure$error_code),
190
+ "Non-auth boot failures should remain generic in request responses."
191
+ )
192
+ auth_config_payload <- asa_api_error_fields(
193
+ auth_config_boot_failure$message,
194
+ auth_config_boot_failure$error_code,
195
+ auth_config_boot_failure$details
196
+ )
197
+ assert_true(
198
+ identical(auth_config_payload$error_code, "auth_config_missing") &&
199
+ identical(
200
+ sort(auth_config_payload$details$missing_env_vars),
201
+ sort(c("GUI_PASSWORD", "ASA_API_BEARER_TOKEN"))
202
+ ),
203
+ "Structured auth-config failures should render with error_code and missing_env_vars details."
204
+ )
205
  assert_true(
206
  is.character(.asa_api_auth_cache$api_bearer_token_hash) &&
207
  nzchar(.asa_api_auth_cache$api_bearer_token_hash) &&
 
250
  isTRUE(asa_api_has_required_gui_password(auth_fixture[["GUI_PASSWORD"]])),
251
  "GUI auth should accept the configured password."
252
  )
253
+ gui_mismatch <- asa_api_check_gui_password("wrong-password")
254
+ assert_true(
255
+ identical(gui_mismatch$ok, FALSE) &&
256
+ identical(gui_mismatch$status_code, 401L) &&
257
+ identical(gui_mismatch$error_code, "credential_mismatch") &&
258
+ identical(gui_mismatch$details$auth_target, "gui_password"),
259
+ "GUI auth should report credential mismatches with a structured safe error."
260
+ )
261
+ gui_mismatch_payload <- asa_api_error_fields(
262
+ gui_mismatch$message,
263
+ gui_mismatch$error_code,
264
+ gui_mismatch$details
265
+ )
266
+ assert_true(
267
+ identical(
268
+ gui_mismatch_payload$error,
269
+ "Unauthorized: provided credential did not match the configured value."
270
+ ) &&
271
+ identical(gui_mismatch_payload$error_code, "credential_mismatch") &&
272
+ identical(gui_mismatch_payload$details$auth_target, "gui_password"),
273
+ "GUI credential mismatches should render the expected response fields."
274
+ )
275
+ api_mismatch <- asa_api_check_bearer_token(mock_request(authorization = "Bearer wrong"))
276
  assert_true(
277
+ identical(api_mismatch$ok, FALSE) &&
278
+ identical(api_mismatch$status_code, 401L) &&
279
+ identical(api_mismatch$error_code, "credential_mismatch") &&
280
+ identical(api_mismatch$details$auth_target, "api_bearer_token"),
281
+ "API auth should report bearer-token mismatches with a structured safe error."
282
  )
283
 
284
  asa_api_clear_auth_cache()
 
287
  asa_api_refresh_auth_cache(force = TRUE),
288
  "ASA_API_BEARER_TOKEN"
289
  )
290
+ gui_missing_auth_config <- asa_api_check_gui_password("anything")
291
+ assert_true(
292
+ identical(gui_missing_auth_config$ok, FALSE) &&
293
+ identical(gui_missing_auth_config$status_code, 503L) &&
294
+ identical(gui_missing_auth_config$error_code, "auth_config_missing") &&
295
+ identical(gui_missing_auth_config$details$missing_env_vars, "ASA_API_BEARER_TOKEN"),
296
+ "Missing auth env vars should surface as structured startup misconfiguration errors."
297
+ )
298
  do.call(Sys.setenv, stats::setNames(list(auth_fixture[["ASA_API_BEARER_TOKEN"]]), "ASA_API_BEARER_TOKEN"))
299
 
300
  asa_api_clear_auth_cache()
 
306
  do.call(Sys.setenv, stats::setNames(list(auth_fixture[["GUI_PASSWORD"]]), "GUI_PASSWORD"))
307
  asa_api_refresh_auth_cache(force = TRUE)
308
 
309
+ source(file.path(repo_root, "R", "plumber.R"))
310
+ res_env <- new.env(parent = emptyenv())
311
+ rendered_payload <- asa_api_error_payload(
312
+ res_env,
313
+ gui_mismatch$status_code,
314
+ gui_mismatch$message,
315
+ gui_mismatch$error_code,
316
+ gui_mismatch$details
317
+ )
318
+ assert_true(
319
+ identical(res_env$status, 401L) &&
320
+ identical(rendered_payload$error_code, "credential_mismatch") &&
321
+ identical(rendered_payload$details$auth_target, "gui_password"),
322
+ "Route error rendering should preserve structured auth diagnostics."
323
+ )
324
+
325
  health_payload <- asa_api_health_payload()
326
  assert_true(
327
  identical(isTRUE(health_payload$direct_provider_available), isTRUE(asa_api_has_run_direct_task())),