Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export EXTENDDB_TEST_ENDPOINT=https://127.0.0.1:18443
export EXTENDDB_ADMIN_USER=admin
export EXTENDDB_ADMIN_PASSWORD=<password-from-init>
eval $(python3 devtools/provision-test-credentials)
# The access key import tests need import enabled. devtools/run-tests sets it.
./target/release/extenddb settings --config extenddb.toml set allow_credential_import true
pytest tests/ -v
```

Expand Down
143 changes: 123 additions & 20 deletions crates/server/src/management/iam_user_self.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>>,
Expand All @@ -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.
Expand Down Expand Up @@ -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)> {
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}"
);
}
}
5 changes: 5 additions & 0 deletions devtools/run-tests
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,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
# 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
Expand Down
2 changes: 1 addition & 1 deletion docs/differences-from-dynamodb.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ ExtendDB exposes runtime settings that have no DynamoDB equivalent:
| `enable_multipart_keys` | `false` | Enable multi-part base table key extension |
| `log_level` | `info` | Runtime log level (trace, debug, info, warn, error) |
| `sqlx_log_level` | `warn` | Separate log level for sqlx query traces |
| `allow_credential_import` | `true` | Allow importing credentials via the management API |
| `allow_credential_import` | `false` | Allow importing credentials via the management API. Only `true` enables it |

## Web Console

Expand Down
24 changes: 13 additions & 11 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,16 +355,16 @@ By default, control plane operations (CreateTable, DeleteTable) emulate real Dyn

### Credential Import

Controls whether `extenddb manage import-access-key` is allowed (default: `true`).
Controls whether `extenddb manage import-access-key` is allowed (default: `false`). An import stores an access key ID and secret that the caller chooses, so only a deployment that sets this to `true` accepts imported keys.

```bash
# Disable credential import
# Enable credential import
./target/release/extenddb settings --config extenddb.toml set \
allow_credential_import false
allow_credential_import true

# Re-enable
# Disable it again
./target/release/extenddb settings --config extenddb.toml set \
allow_credential_import true
allow_credential_import false
```

### GSI Propagation Delay
Expand Down Expand Up @@ -918,6 +918,13 @@ Admins can also create access keys on behalf of any user:

Import real AWS credentials (or any AKIA* key) into extenddb. This enables the "just change the endpoint URL" workflow — use the same credentials against both extenddb and real DynamoDB.

The `allow_credential_import` runtime setting gates credential import (default: `false`). Enable it before the first import:

```bash
./target/release/extenddb settings --config extenddb.toml set \
allow_credential_import true
```

Both `--secret-access-key` and `--yes` are required:

```bash
Expand All @@ -930,12 +937,7 @@ Both `--secret-access-key` and `--yes` are required:

> **Security note:** `--secret-access-key` and `--password` are visible in `ps` output and shell history. For sensitive environments, use the `EXTENDDB_ADMIN_PASSWORD` environment variable for the password.

Credential import is gated by the `allow_credential_import` runtime setting (default: `true`). To disable:

```bash
./target/release/extenddb settings --config extenddb.toml set \
allow_credential_import false
```
Set `allow_credential_import` back to `false` to refuse further imports. Keys already imported keep working.

Every import is logged to syslog (access key ID, account, user — never the secret).

Expand Down
6 changes: 4 additions & 2 deletions docs/manuals/05-admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,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 |
| `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. |

Expand Down Expand Up @@ -290,7 +290,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 \
Expand Down
3 changes: 3 additions & 0 deletions docs/manuals/06-developer-test-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ The `run-tests` script automatically:
- Sets `control_plane_delay_seconds` to 0.05 for fast test cycles
- Sets `index_propagation_delay_ms` to 0 for immediate GSI updates
- Enables throttling for production-like behavior
- Sets `allow_credential_import` to `true` for the access key import tests
- Configures import/export paths for file operation tests
- Extracts and exports `EXTENDDB_TEST_PG_CONNECTION_STRING` for CLI lifecycle tests

Expand Down Expand Up @@ -195,6 +196,8 @@ devtools/run-tests --extenddb --pytest --parallel
devtools/run-tests --extenddb --pytest --parallel=4

# Direct execution (for quick iteration during development)
# The access key import tests need import enabled. devtools/run-tests sets it.
./target/debug/extenddb settings --config extenddb.toml set allow_credential_import true
python3 -m pytest tests/ -v
python3 -m pytest tests/test_put_item.py -v
python3 -m pytest tests/test_put_item.py::test_put_item_basic -v
Expand Down
32 changes: 32 additions & 0 deletions docs/manuals/07-upgrade-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,38 @@ migration is required.

---

### v0.1.15: access key import is refused unless `allow_credential_import` is `true`

**What changed.** `extenddb manage import-access-key`, and the management API
route it calls, `POST /management/accounts/{id}/users/{name}/access-keys/import`,
import a key only when the runtime setting `allow_credential_import` is `true`.
When the catalog has no row for the setting, the server refuses the import with
HTTP 403 and `Credential import is disabled (allow_credential_import = false)`.
Before this release the server allowed the import when the row was missing. No
release writes the row, so every deployment that never set it accepted imports.
An import stores an access key ID and secret that the caller chooses. With the
feature off by default, only deployments that turn it on accept caller-chosen
keys. The console settings page already showed `false` as the default. The
setting does not affect keys imported before the upgrade, or the credentials a
dev-mode server seeds at startup.

**Who is affected.** Deployments that import access keys and never set
`allow_credential_import`. A deployment that set it to `true` or `false` keeps
its behavior.

**Migration.** Before upgrading, enable the setting on each deployment that
imports keys:

```bash
extenddb settings --config extenddb.toml set allow_credential_import true
```

The command writes the row to the catalog. The server reads the row on each
import, so the command works with the earlier release and needs no restart. No
catalog migration is required.

---

### v0.1.14 LeadingKeys now enforced on Query, batch and transaction operations

**What changed.** `dynamodb:LeadingKeys` is now evaluated on `Query`,
Expand Down
2 changes: 1 addition & 1 deletion docs/manuals/11-deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ extenddb init \
- [ ] Set `auth.provider = "builtin"` in `extenddb.toml`
- [ ] Change the admin password from the auto-generated one
- [ ] Create named accounts and IAM users with least-privilege policies
- [ ] Disable credential import if not needed: `extenddb settings set allow_credential_import false`
- [ ] Leave credential import off unless you need it. The server refuses imports unless `allow_credential_import` is `true`. If you enabled it to import keys, turn it off again: `extenddb settings set allow_credential_import false`

### Transport

Expand Down
10 changes: 10 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,16 @@ The error log line names the exact relations for one leaked table and gives this

**Fix:** Check PostgreSQL connectivity and logs.

### `Credential import is disabled (allow_credential_import = false)`

**Cause:** The server refused an access key import (`extenddb manage import-access-key`, or `POST /management/accounts/{id}/users/{name}/access-keys/import`) with HTTP 403 because the `allow_credential_import` runtime setting is not `true`. The message is the same when the catalog has no row for the setting. From v0.1.15 the server refuses the import when the row is missing. A deployment that imported keys before the upgrade without setting it gets this error after the upgrade.

**Fix:** Enable import, then retry. The next import reads the new value, so you do not need to restart the server:
```bash
extenddb settings --config extenddb.toml set allow_credential_import true
```
Keys imported earlier keep working while import is disabled.

### `Management API: check allow_credential_import failed: <error>`

**Cause:** The query to check the `allow_credential_import` runtime setting failed during an access key import. Likely a database connectivity issue.
Expand Down
4 changes: 3 additions & 1 deletion tests/cli/test-cli-comprehensive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,9 @@ $EXTENDDB manage --config "$CONFIG" --user "${ACCOUNT_ID1}/user1" --password 'Us
list-access-keys --account-id "$ACCOUNT_ID1" --user-name user1 >/dev/null 2>&1; RC=$?
assert_ok $RC "self-service list-access-keys"

# import-access-key
# import-access-key (refused unless allow_credential_import is true)
$EXTENDDB settings --config "$CONFIG" set allow_credential_import true >/dev/null 2>&1; RC=$?
assert_ok $RC "settings set allow_credential_import true"
manage import-access-key --account-id "$ACCOUNT_ID1" --user-name user1 \
--access-key-id AKIAIOSFODNN7EXAMPLE --secret-access-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY --yes >/dev/null 2>&1; RC=$?
assert_ok $RC "import-access-key"
Expand Down
Loading
Loading