Repository navigation
fix(management): refuse access key import unless allow_credential_import is true #840
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
Changes from all commits
669b299
e04c9c1
93361fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ use std::sync::Arc; | |
| use axum::extract::{ConnectInfo, Path, State}; | ||
| use axum::http::{HeaderMap, StatusCode}; | ||
| use axum::response::{IntoResponse, Response}; | ||
| use extenddb_storage::management_store::SettingsStore; | ||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| use super::ManagementState; | ||
|
|
@@ -223,6 +224,27 @@ pub async fn change_user_password( | |
| } | ||
| } | ||
|
|
||
| /// Checks the runtime setting `allow_credential_import` before | ||
| /// [`import_access_key`] stores a key. Only the stored value `true` allows the | ||
| /// import. No row, `false`, and any other value refuse it with 403, because an | ||
| /// import stores an access key ID and secret that the caller chooses. A store | ||
| /// error is answered with 500. | ||
| #[allow(clippy::result_large_err)] // axum Response as rejection is this module's idiom | ||
| async fn credential_import_gate(settings: &dyn SettingsStore) -> Result<(), Response> { | ||
| match settings.get_setting("allow_credential_import").await { | ||
| Ok(Some(value)) if value == "true" => Ok(()), | ||
| Ok(_) => Err(( | ||
| StatusCode::FORBIDDEN, | ||
| "Credential import is disabled (allow_credential_import = false)", | ||
| ) | ||
| .into_response()), | ||
| Err(e) => { | ||
| tracing::error!("Management API: check allow_credential_import failed: {e:?}"); | ||
| Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// `POST /management/accounts/{id}/users/{name}/access-keys/import` — import an external access key. | ||
| pub async fn import_access_key( | ||
| State(state): State<Arc<ManagementState>>, | ||
|
|
@@ -240,26 +262,8 @@ pub async fn import_access_key( | |
| return e; | ||
| } | ||
|
|
||
| // Check runtime setting gate. | ||
| let allowed: bool = match state | ||
| .catalog_store | ||
| .get_setting("allow_credential_import") | ||
| .await | ||
| { | ||
| Ok(Some(v)) => v == "true", | ||
| Ok(None) => true, // Default: allowed. | ||
| Err(e) => { | ||
| tracing::error!("Management API: check allow_credential_import failed: {e:?}"); | ||
| return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| } | ||
| }; | ||
|
|
||
| if !allowed { | ||
| return ( | ||
| StatusCode::FORBIDDEN, | ||
| "Credential import is disabled (allow_credential_import = false)", | ||
| ) | ||
| .into_response(); | ||
| if let Err(refused) = credential_import_gate(&*state.catalog_store).await { | ||
| return refused; | ||
| } | ||
|
|
||
| // Validate access key ID format. | ||
|
|
@@ -302,3 +306,102 @@ pub async fn import_access_key( | |
| Err(e) => op_err_to_response(OpError::from_storage(e)), | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use extenddb_storage::management_store::{OpError, OpResult}; | ||
| use futures::future::BoxFuture; | ||
|
|
||
| use super::*; | ||
| use crate::rate_limit::tests::capture_logs; | ||
|
|
||
| /// What the settings store answers for `allow_credential_import`. | ||
| enum Setting { | ||
| /// The catalog has no row for the key. | ||
| NoRow, | ||
| /// The catalog holds this value. | ||
| Value(&'static str), | ||
| /// The read fails. | ||
| StoreError, | ||
| } | ||
|
|
||
| impl SettingsStore for Setting { | ||
| fn get_setting(&self, key: &str) -> BoxFuture<'_, OpResult<Option<String>>> { | ||
| assert_eq!(key, "allow_credential_import"); | ||
| let answer = match self { | ||
| Self::NoRow => Ok(None), | ||
| Self::Value(value) => Ok(Some((*value).to_owned())), | ||
| Self::StoreError => Err(OpError::Internal("store is down".to_owned())), | ||
| }; | ||
| Box::pin(async move { answer }) | ||
| } | ||
|
|
||
| fn set_setting(&self, _: &str, _: &str) -> BoxFuture<'_, OpResult<()>> { | ||
| unreachable!("the import gate does not write settings") | ||
| } | ||
|
|
||
| fn list_settings(&self) -> BoxFuture<'_, OpResult<Vec<(String, String)>>> { | ||
| unreachable!("the import gate does not list settings") | ||
| } | ||
| } | ||
|
|
||
| /// The gate's answer as a status code, with the body of a refusal. | ||
| async fn gate(setting: Setting) -> Result<(), (StatusCode, String)> { | ||
| match credential_import_gate(&setting).await { | ||
| Ok(()) => Ok(()), | ||
| Err(response) => { | ||
| let status = response.status(); | ||
| let body = axum::body::to_bytes(response.into_body(), usize::MAX) | ||
| .await | ||
| .expect("the refusal body is in memory"); | ||
| Err((status, String::from_utf8_lossy(&body).into_owned())) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn refused() -> Result<(), (StatusCode, String)> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: one constant for the refusal text. The string is written out in the gate, here in
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #919 lists this for after this PR merges. |
||
| Err(( | ||
| StatusCode::FORBIDDEN, | ||
| "Credential import is disabled (allow_credential_import = false)".to_owned(), | ||
| )) | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn no_row_refuses_the_import() { | ||
| assert_eq!(gate(Setting::NoRow).await, refused()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn false_refuses_the_import() { | ||
| assert_eq!(gate(Setting::Value("false")).await, refused()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn true_allows_the_import() { | ||
| assert_eq!(gate(Setting::Value("true")).await, Ok(())); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn a_value_other_than_true_refuses_the_import() { | ||
| // The settings API writes only `true` or `false`. A row edited in the | ||
| // catalog by hand can hold any other value. | ||
| for value in ["TRUE", "True", "1", "yes", "", " true"] { | ||
| assert_eq!(gate(Setting::Value(value)).await, refused(), "{value:?}"); | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn a_store_error_answers_500_and_is_logged() { | ||
| let (logs, _guard) = capture_logs(); | ||
| assert_eq!( | ||
| gate(Setting::StoreError).await, | ||
| Err((StatusCode::INTERNAL_SERVER_ERROR, String::new())) | ||
| ); | ||
| let text = logs.text(); | ||
| assert!( | ||
| text.contains("ERROR") | ||
| && text.contains("Management API: check allow_credential_import failed"), | ||
| "{text}" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -402,6 +402,11 @@ if $NEEDS_INTEGRATION && [[ "$TARGET" != "real-dynamodb" ]]; then | |
| if "$BINARY" settings --config "$CONFIG_FOR_SETTINGS" set throttling_enabled true 2>/dev/null; then | ||
| echo " ✓ throttling_enabled set to true" | ||
| fi | ||
| # Access key import is refused unless this is true. Set once here, before | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: make the failure clear outside this script.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #919 lists this for after this PR merges. |
||
| # any suite starts, so no test changes it while parallel workers run. | ||
| if "$BINARY" settings --config "$CONFIG_FOR_SETTINGS" set allow_credential_import true 2>/dev/null; then | ||
| echo " ✓ allow_credential_import set to true" | ||
| fi | ||
| fi | ||
|
|
||
| # Import/export paths. The Python tests write fixtures via tempfile, which | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -255,7 +255,7 @@ Managed via `extenddb settings set`. Changes take effect within 30 seconds witho | |
| |---------|---------|-------------| | ||
| | `log_level` | `info` | Log level: trace, debug, info, warn, error | | ||
| | `control_plane_delay_seconds` | `0.25` | Delay for table status transitions, in seconds (0 = instant). PostgreSQL and SQLite write 0.25 into the catalog when it is created; MongoDB and Cassandra use 0.25 when the setting is absent. On PostgreSQL and SQLite a value below 1 makes DeleteTable remove the table in the request; on PostgreSQL, if a session holds a lock on the table's data (a reader, an open write transaction, an index build), DeleteTable returns `DELETING` instead (after at most about a second), the drop finishes in the background within about a second of that session's release, and a CreateTable of the same name is refused until then. | | ||
| | `allow_credential_import` | `true` | Whether `import-access-key` is allowed | | ||
| | `allow_credential_import` | `false` | Whether `import-access-key` is allowed. Only `true` enables it | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up, not this PR: the CLI does not show this default. On a fresh SQLite catalog from this branch,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filed as #919. |
||
| | `vector_backfill_batch_delay_ms` | `0` | **Test-oriented.** Milliseconds to pause between batches while a vector index backfills. Zero in production. A test sets it so a write is guaranteed to land while the index is still building. The pause is outside the batch transaction, so writes are still accepted throughout, but it does extend the per-table propagation hold: no index on that table advances while the build runs, GSIs included, and the accepted range goes up to 60 s per batch. | | ||
| | `vector_allocation_phase_delay_ms` | `0` | **Test-oriented.** Milliseconds to hold a new vector index in the resource-allocation phase (`CREATING` with `Backfilling: false`) before the scan starts. Zero in production. Without it the phase lasts only from the `UpdateTable` transaction, which inserts the row as `CREATING` with `Backfilling: false`, until the detached build task flips the flag, which is a window no client can time reliably rather than one that cannot exist. | | ||
|
|
||
|
|
@@ -359,7 +359,9 @@ Account IDs must be 12-digit numeric strings (matching AWS format). If `--accoun | |
| delete-access-key --account-id 123456789012 \ | ||
| --user-name alice --access-key-id AKIAEXTENDDB... | ||
|
|
||
| # Import existing credentials | ||
| # Import existing credentials (refused unless allow_credential_import is true) | ||
| ./target/release/extenddb settings --config extenddb.toml set \ | ||
| allow_credential_import true | ||
| ./target/release/extenddb manage --user admin --password <pw> \ | ||
| import-access-key --account-id 123456789012 --user-name alice \ | ||
| --access-key-id AKIAIOSFODNN7EXAMPLE \ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (optional): make the refusal say what to do. With the default now
false, the message is accurate even when there is no row. But it does not say how to turn import on. Something likeCredential import is disabled. Set allow_credential_import to true to enable itwould save a trip to the troubleshooting guide. I see that the PR keeps the old text on purpose, so this is your call. If you change it, the troubleshooting heading changes with it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kept the text in this PR, because the troubleshooting heading and the pytest match it. #919 lists the change for after this PR merges.