From 64350fb33dcf76ca7876d57841a2ce700d149f47 Mon Sep 17 00:00:00 2001 From: Hitesh Sharma Date: Thu, 5 Nov 2026 18:00:20 -0800 Subject: [PATCH 1/2] fix(dev-mode): Let operators drop the public example credential The dev image binds 0.0.0.0 inside its container, serves plain HTTP, opens authorization, and always seeds AWS's public example key pair. Published with a bare `-p 18080:18080` instead of the documented `-p 127.0.0.1:18080:18080`, anyone who can reach the host's port can read, change and delete every table. The server cannot see the host's port mapping, so it cannot tell which case it is in. Add EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL. Set to 1 or true, it drops the example pair, so only the EXTENDDB_DEV_* pair verifies. It requires that pair, so the server never starts with no credential, rejects any value other than 1, true, 0 or false, so a typo cannot leave the public key active, and removes an example key an earlier boot stored in a file-backed catalog, which seeding alone would leave verifying. The default is unchanged: tools that hardcode the example pair keep working. The dev banner said "plain HTTP on loopback" whatever the bind was. It now names the bind address and, when that is not loopback, prints a warning block: what exposure means, the loopback publish command, and the new switch. The non-loopback log warning names the switch too. Move the "local only, not for shared networks" warning to the top of the dev image's Docker Hub overview and docs/dev-image.md, and document the switch. Publish the MongoDB overview's example on loopback, as the text beneath it already says it is. --- crates/app/src/cmd_serve.rs | 209 +++++++++++++++++++++--- crates/server/src/dev_credentials.rs | 236 +++++++++++++++++++++------ crates/server/src/serve.rs | 7 +- docker/dockerhub-overview-dev.md | 6 +- docker/dockerhub-overview-mongodb.md | 2 +- docs/dev-image.md | 48 +++++- 6 files changed, 426 insertions(+), 82 deletions(-) diff --git a/crates/app/src/cmd_serve.rs b/crates/app/src/cmd_serve.rs index 4fd7ad61d..2b22a331e 100755 --- a/crates/app/src/cmd_serve.rs +++ b/crates/app/src/cmd_serve.rs @@ -115,11 +115,7 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> { // cannot enable `dev-mode` (see the compile_error in the bin crate). if cfg!(feature = "dev-mode") { let host = app_config.server.bind_addr.trim(); - let loopback = host == "localhost" - || host - .parse::() - .map(|ip| ip.is_loopback()) - .unwrap_or(false); + let loopback = is_loopback_host(host); let allow_any_bind = std::env::var("EXTENDDB_DEV_ALLOW_ANY_BIND") .is_ok_and(|v| matches!(v.trim(), "1" | "true")); if !loopback && !allow_any_bind { @@ -134,7 +130,9 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> { bind_addr = host, "dev-mode is binding a non-loopback address: plain HTTP with relaxed \ authorization. Publish it on loopback only (-p 127.0.0.1:PORT:PORT) and \ - never expose it to a shared network." + never expose it to a shared network. On a shared host, also set \ + EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1 with your own \ + EXTENDDB_DEV_ACCESS_KEY_ID / EXTENDDB_DEV_SECRET_ACCESS_KEY." ); } } @@ -198,7 +196,7 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> { print!("{startup_warnings}"); } if cfg!(feature = "dev-mode") { - print_dev_mode_banner(&app_config, args.foreground)?; + print_dev_mode_banner(&app_config, port, args.foreground)?; } // D-3: A PID file lets `extenddb status` and `extenddb stop` find the @@ -385,55 +383,214 @@ fn render_startup_warnings(warnings: &[extenddb_storage::config::StartupWarning] out } +/// True for a bind address that only this host can reach. +fn is_loopback_host(host: &str) -> bool { + host == "localhost" + || host + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + /// Print the developer-mode banner lines: the mode notice, the credentials to -/// sign with, and an advisory when an ambient `AWS_ACCESS_KEY_ID` would not -/// verify. +/// sign with, a warning block when the server is not bound to loopback, and an +/// advisory when an ambient `AWS_ACCESS_KEY_ID` would not verify. /// /// Resolving here (not just in `serve()`) fails fast on a misconfigured /// `EXTENDDB_DEV_*` pair before daemonizing, and gives the invoking user the /// credentials without digging through logs. The built-in example pair is /// printed in full: it is public AWS documentation, not a secret. An /// operator-supplied secret is never printed. -fn print_dev_mode_banner(app_config: &config::AppConfig, foreground: bool) -> anyhow::Result<()> { +fn print_dev_mode_banner( + app_config: &config::AppConfig, + port: u16, + foreground: bool, +) -> anyhow::Result<()> { use extenddb_server::dev_credentials; let dev_creds = dev_credentials::resolve()?; + let lines = dev_mode_banner_lines( + &config::redact_password(app_config.storage.connection_config()), + app_config.server.bind_addr.trim(), + port, + &dev_creds, + dev_credentials::ignored_aws_env_key(&dev_creds).as_deref(), + ); + // Same stream split as the main banner: stdout for the daemon parent, + // stderr in foreground mode so supervisors capture one stream. + for msg in lines { + if foreground { + eprintln!("{msg}"); + } else { + println!("{msg}"); + } + } + Ok(()) +} + +/// Build the developer-mode banner. Pure, so the wording is unit tested. +/// +/// A non-loopback bind gets a multi-line warning block rather than one log +/// line: it is the dev image's normal state (a container must bind 0.0.0.0 +/// for port publishing to work), and whether that is safe depends on the +/// host's `-p` mapping, which the server cannot see. So the block cannot say +/// "you are exposed", only what exposure would mean and how to avoid it. +fn dev_mode_banner_lines( + storage: &str, + host: &str, + port: u16, + dev_creds: &[extenddb_server::dev_credentials::DevCredential], + ignored_aws_key: Option<&str>, +) -> Vec { + use extenddb_server::dev_credentials; + + let loopback = is_loopback_host(host); + let example_active = dev_credentials::example_pair_active(dev_creds); + let where_ = if loopback { + "on loopback".to_owned() + } else { + format!("on {host}") + }; let mut lines = vec![ format!( - " DEVELOPER MODE: plain HTTP on loopback, authorization open \ - (SigV4 still enforced). Serving storage: {}. Not for production.", - config::redact_password(app_config.storage.connection_config()), + " DEVELOPER MODE: plain HTTP {where_}, authorization open \ + (SigV4 still enforced). Serving storage: {storage}. Not for production.", ), format!( " Sign requests with: {}", - dev_credentials::describe(&dev_creds) + dev_credentials::describe(dev_creds) ), ]; - if let Some(ignored) = dev_credentials::ignored_aws_env_key(&dev_creds) { + if !loopback { + let credential = if example_active { + "the signing credential above is public, so anyone who can reach this port \ + can read, change and delete every table" + } else { + "whoever holds the signing credential above can read, change and delete \ + every table" + }; + lines.extend([ + String::new(), + format!(" WARNING: dev mode is listening on {host}:{port}, not loopback."), + format!(" Requests are plain HTTP and authorization is open: {credential}."), + format!( + " In a container, publish the port on loopback only: \ + -p 127.0.0.1:{port}:{port} (not -p {port}:{port})." + ), + " Never expose dev mode to a shared or routable network.".to_owned(), + ]); + if example_active { + lines.push(format!( + " On a shared host or CI runner, set {}=1 with your own \ + EXTENDDB_DEV_ACCESS_KEY_ID / EXTENDDB_DEV_SECRET_ACCESS_KEY.", + dev_credentials::ENV_DISABLE_EXAMPLE, + )); + } + lines.push(String::new()); + } + if let Some(ignored) = ignored_aws_key { lines.push(format!( " Note: AWS_ACCESS_KEY_ID is set but not adopted; requests signed with \ '{ignored}' will be rejected. Set EXTENDDB_DEV_ACCESS_KEY_ID and \ EXTENDDB_DEV_SECRET_ACCESS_KEY to seed an additional credential." )); } - // Same stream split as the main banner: stdout for the daemon parent, - // stderr in foreground mode so supervisors capture one stream. - for msg in lines { - if foreground { - eprintln!("{msg}"); - } else { - println!("{msg}"); - } - } - Ok(()) + lines } #[cfg(test)] mod tests { - use super::{ServeArgs, render_startup_warnings}; + use super::{ServeArgs, dev_mode_banner_lines, is_loopback_host, render_startup_warnings}; use clap::Parser; + use extenddb_server::dev_credentials::{ + DevCredential, ENV_DISABLE_EXAMPLE, EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET_ACCESS_KEY, + }; use extenddb_storage::config::StartupWarning; + fn example() -> DevCredential { + DevCredential { + access_key_id: EXAMPLE_ACCESS_KEY_ID.to_owned(), + secret_access_key: EXAMPLE_SECRET_ACCESS_KEY.to_owned(), + is_builtin: true, + } + } + + fn team() -> DevCredential { + DevCredential { + access_key_id: "AKIADEVTEAMKEY000001".to_owned(), + secret_access_key: "team-secret".to_owned(), + is_builtin: false, + } + } + + #[test] + fn loopback_hosts_are_recognised() { + for host in ["127.0.0.1", "127.1.2.3", "::1", "localhost"] { + assert!(is_loopback_host(host), "{host}"); + } + for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", ""] { + assert!(!is_loopback_host(host), "{host}"); + } + } + + #[test] + fn loopback_dev_banner_has_no_exposure_warning() { + let text = + dev_mode_banner_lines(":memory:", "127.0.0.1", 18080, &[example()], None).join("\n"); + assert!(text.contains("plain HTTP on loopback"), "{text}"); + assert!(!text.contains("WARNING"), "{text}"); + } + + #[test] + fn non_loopback_dev_banner_warns_with_the_publish_command() { + let text = + dev_mode_banner_lines(":memory:", "0.0.0.0", 18080, &[example()], None).join("\n"); + // The banner used to say "on loopback" whatever the bind was. + assert!(!text.contains("plain HTTP on loopback"), "{text}"); + assert!(text.contains("plain HTTP on 0.0.0.0"), "{text}"); + assert!( + text.contains("WARNING: dev mode is listening on 0.0.0.0:18080"), + "{text}" + ); + assert!(text.contains("credential above is public"), "{text}"); + assert!(text.contains("-p 127.0.0.1:18080:18080"), "{text}"); + assert!(text.contains(&format!("{ENV_DISABLE_EXAMPLE}=1")), "{text}"); + } + + #[test] + fn non_loopback_dev_banner_without_the_example_pair_drops_the_public_wording() { + let text = dev_mode_banner_lines(":memory:", "0.0.0.0", 9000, &[team()], None).join("\n"); + assert!( + text.contains("WARNING: dev mode is listening on 0.0.0.0:9000"), + "{text}" + ); + assert!(text.contains("-p 127.0.0.1:9000:9000"), "{text}"); + assert!(!text.contains("is public"), "{text}"); + assert!(!text.contains(EXAMPLE_ACCESS_KEY_ID), "{text}"); + // The switch is already set; do not tell the operator to set it. + assert!( + !text.contains(&format!("{ENV_DISABLE_EXAMPLE}=1")), + "{text}" + ); + assert!(!text.contains("team-secret"), "{text}"); + } + + #[test] + fn dev_banner_keeps_the_ignored_aws_key_note() { + let text = dev_mode_banner_lines( + ":memory:", + "127.0.0.1", + 18080, + &[example()], + Some("ASIAFOREIGNSESSION01"), + ) + .join("\n"); + assert!( + text.contains("'ASIAFOREIGNSESSION01' will be rejected"), + "{text}" + ); + } + /// Test wrapper so clap has a top-level `Parser` to drive `ServeArgs`. #[derive(Parser)] struct TestCli { diff --git a/crates/server/src/dev_credentials.rs b/crates/server/src/dev_credentials.rs index 155b06b2e..adeffb7a2 100644 --- a/crates/server/src/dev_credentials.rs +++ b/crates/server/src/dev_credentials.rs @@ -4,13 +4,13 @@ //! Developer-mode credential vending. //! //! Dev mode verifies `SigV4` exactly like production, so the server must know -//! every credential an SDK may sign with. The contract is "always the example -//! pair, plus at most one explicit extra": +//! every credential an SDK may sign with. The contract is "the example pair +//! unless explicitly disabled, plus at most one explicit extra": //! //! * The built-in credential is AWS's documented example pair. It is seeded on -//! every dev-mode boot, unconditionally, so tools that hardcode it (`NoSQL +//! every dev-mode boot by default, so tools that hardcode it (`NoSQL //! Workbench`'s `ExtendDB` connection type, the `@extenddb/dev` launchers, the -//! docs and CI) keep working regardless of operator configuration. An SDK +//! docs and CI) keep working with no operator configuration. An SDK //! must know a credential up front (it cannot read a printed banner), which //! is why the default is well-known rather than generated. The pair is //! public AWS documentation, recognised by secret scanners as non-live, so @@ -19,6 +19,13 @@ //! neither) seed one additional credential. The dedicated names follow the //! `EXTENDDB_DEV_ALLOW_ANY_BIND` precedent: dev-only levers get dev-only //! variables, read only when `dev-mode` is compiled in. +//! * `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1` drops the example pair, for a +//! dev server that is reachable by others (a shared host, a CI runner with a +//! routable address): the public key would otherwise open every table to +//! anyone who can reach the port. It requires the `EXTENDDB_DEV_*` pair, so +//! the server always has a credential to verify, and it removes an example +//! key an earlier boot stored in a file-backed catalog, so the key stops +//! verifying rather than lingering. //! * The standard `AWS_*` variables are never read for seeding. Adopting them //! meant a shell holding real long-lived credentials silently copied them //! into the dev catalog, a shell holding SSO session credentials (`ASIA*`) @@ -37,8 +44,9 @@ use extenddb_storage::CatalogStore; use extenddb_storage::management_store::OpError; -/// Access key id of AWS's documented example credential. Always seeded in dev -/// mode; public contract for every tool that hardcodes the pair. +/// Access key id of AWS's documented example credential. Seeded in dev mode +/// unless `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL` is set; public contract for +/// every tool that hardcodes the pair. pub const EXAMPLE_ACCESS_KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE"; /// Secret half of AWS's documented example credential. Public documentation, /// not a secret: safe to print and to commit in fixtures. @@ -46,6 +54,8 @@ pub const EXAMPLE_SECRET_ACCESS_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMP const ENV_ACCESS_KEY_ID: &str = "EXTENDDB_DEV_ACCESS_KEY_ID"; const ENV_SECRET_ACCESS_KEY: &str = "EXTENDDB_DEV_SECRET_ACCESS_KEY"; +/// Environment variable that drops the built-in example pair. +pub const ENV_DISABLE_EXAMPLE: &str = "EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL"; /// A credential the dev server seeds and verifies `SigV4` against. pub struct DevCredential { @@ -78,17 +88,21 @@ impl std::fmt::Debug for DevCredential { /// Resolve the dev-mode credential set from the process environment. /// -/// Element 0 is always the built-in example pair; element 1, when present, is -/// the operator's `EXTENDDB_DEV_*` pair. +/// Element 0 is the built-in example pair unless +/// `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL` drops it; the operator's +/// `EXTENDDB_DEV_*` pair, when present, follows it. /// /// # Errors /// /// Returns an error when only one of the two variables is set, when the key id -/// is not AKIA-shaped, or when it collides with the built-in key id. +/// is not AKIA-shaped, when it collides with the built-in key id, when the +/// disable switch holds an unrecognised value, or when the example pair is +/// disabled without an `EXTENDDB_DEV_*` pair to replace it. pub fn resolve() -> anyhow::Result> { resolve_from( non_empty_env(ENV_ACCESS_KEY_ID), non_empty_env(ENV_SECRET_ACCESS_KEY), + parse_switch(ENV_DISABLE_EXAMPLE, non_empty_env(ENV_DISABLE_EXAMPLE))?, ) } @@ -96,17 +110,40 @@ fn non_empty_env(name: &str) -> Option { std::env::var(name).ok().filter(|s| !s.is_empty()) } +/// Parse an on/off switch. Unset or empty is off. An unrecognised value is an +/// error rather than "off": this switch removes a public credential, so a typo +/// such as `yes` must not leave the server quietly accepting it. +fn parse_switch(name: &str, value: Option) -> anyhow::Result { + let Some(value) = value else { + return Ok(false); + }; + match value.trim() { + "1" | "true" => Ok(true), + "0" | "false" | "" => Ok(false), + other => anyhow::bail!("{name} must be 1, true, 0 or false (got '{other}')."), + } +} + fn resolve_from( custom_key_id: Option, custom_secret: Option, + disable_example: bool, ) -> anyhow::Result> { - let mut credentials = vec![DevCredential { - access_key_id: EXAMPLE_ACCESS_KEY_ID.to_owned(), - secret_access_key: EXAMPLE_SECRET_ACCESS_KEY.to_owned(), - is_builtin: true, - }]; + let mut credentials = Vec::with_capacity(2); + if !disable_example { + credentials.push(DevCredential { + access_key_id: EXAMPLE_ACCESS_KEY_ID.to_owned(), + secret_access_key: EXAMPLE_SECRET_ACCESS_KEY.to_owned(), + is_builtin: true, + }); + } let (key_id, secret) = match (custom_key_id, custom_secret) { + (None, None) if disable_example => anyhow::bail!( + "{ENV_DISABLE_EXAMPLE} is set, which removes the built-in example credential, \ + but no replacement is configured; set {ENV_ACCESS_KEY_ID} and \ + {ENV_SECRET_ACCESS_KEY} so requests have a credential to sign with." + ), (None, None) => return Ok(credentials), (Some(k), Some(s)) => (k, s), (Some(_), None) => anyhow::bail!( @@ -129,14 +166,14 @@ fn resolve_from( if !key_id.starts_with("AKIA") { anyhow::bail!( "{ENV_ACCESS_KEY_ID} must be AKIA-shaped (got '{key_id}'). The built-in \ - example pair {EXAMPLE_ACCESS_KEY_ID} is always seeded; clients migrating \ + example pair {EXAMPLE_ACCESS_KEY_ID} is seeded by default; clients migrating \ from DynamoDB Local dummy credentials can sign with it instead." ); } if key_id == EXAMPLE_ACCESS_KEY_ID { anyhow::bail!( - "{ENV_ACCESS_KEY_ID} matches the built-in example key id, which is always \ - seeded with its documented secret; choose a different key id." + "{ENV_ACCESS_KEY_ID} matches the built-in example key id, which is reserved \ + for its documented secret; choose a different key id." ); } @@ -248,9 +285,35 @@ pub async fn seed( Err(e) => anyhow::bail!("dev mode: failed to import dev credential {key_id}: {e:?}"), } } + + // The example pair is disabled: remove it if an earlier boot stored it in + // a file-backed catalog. Seeding only adds keys, so without this the public + // key would keep verifying on every restart after the switch was set. + if !example_pair_active(credentials) { + match catalog_store + .delete_access_key(&account_id, "dev", EXAMPLE_ACCESS_KEY_ID) + .await + { + Ok(()) => tracing::info!( + "dev credential {EXAMPLE_ACCESS_KEY_ID}: removed from the catalog \ + ({ENV_DISABLE_EXAMPLE} is set)" + ), + Err(OpError::NotFound(_)) => {} + Err(e) => anyhow::bail!( + "dev mode: failed to remove the example credential \ + {EXAMPLE_ACCESS_KEY_ID}: {e:?}" + ), + } + } Ok(()) } +/// True when the built-in example pair is among the resolved credentials. +#[must_use] +pub fn example_pair_active(credentials: &[DevCredential]) -> bool { + credentials.iter().any(|c| c.is_builtin) +} + /// One-line description of the seeded credentials for banners and logs. /// /// The built-in pair is printed in full (public documentation); a custom @@ -263,7 +326,8 @@ pub fn describe(credentials: &[DevCredential]) -> String { .map(|c| { if c.is_builtin { format!( - "{} / {} (AWS documented example pair, always seeded)", + "{} / {} (AWS documented example pair; public, set {ENV_DISABLE_EXAMPLE}=1 \ + to remove it)", c.access_key_id, c.secret_access_key ) } else { @@ -281,37 +345,97 @@ pub fn describe(credentials: &[DevCredential]) -> String { mod tests { use super::*; + const TEAM_KEY: &str = "AKIADEVTEAMKEY000001"; + + fn team_pair() -> (Option, Option) { + (Some(TEAM_KEY.into()), Some("team-secret".into())) + } + #[test] fn no_env_yields_builtin_only() { - let creds = resolve_from(None, None).unwrap(); + let creds = resolve_from(None, None, false).unwrap(); assert_eq!(creds.len(), 1); assert!(creds[0].is_builtin); assert_eq!(creds[0].access_key_id, EXAMPLE_ACCESS_KEY_ID); assert_eq!(creds[0].secret_access_key, EXAMPLE_SECRET_ACCESS_KEY); + assert!(example_pair_active(&creds)); } #[test] fn custom_pair_is_added_after_builtin() { - let creds = resolve_from( - Some("AKIADEVTEAMKEY000001".into()), - Some("team-secret".into()), - ) - .unwrap(); + let (k, s) = team_pair(); + let creds = resolve_from(k, s, false).unwrap(); assert_eq!(creds.len(), 2); assert!(creds[0].is_builtin); assert!(!creds[1].is_builtin); - assert_eq!(creds[1].access_key_id, "AKIADEVTEAMKEY000001"); + assert_eq!(creds[1].access_key_id, TEAM_KEY); assert_eq!(creds[1].secret_access_key, "team-secret"); } + #[test] + fn disabling_the_example_pair_leaves_only_the_custom_pair() { + let (k, s) = team_pair(); + let creds = resolve_from(k, s, true).unwrap(); + assert_eq!(creds.len(), 1); + assert!(!creds[0].is_builtin); + assert_eq!(creds[0].access_key_id, TEAM_KEY); + assert!(!example_pair_active(&creds)); + } + + #[test] + fn disabling_the_example_pair_without_a_replacement_is_rejected() { + let err = resolve_from(None, None, true).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(ENV_DISABLE_EXAMPLE), "{msg}"); + assert!(msg.contains("EXTENDDB_DEV_ACCESS_KEY_ID"), "{msg}"); + assert!(msg.contains("EXTENDDB_DEV_SECRET_ACCESS_KEY"), "{msg}"); + } + + #[test] + fn disabling_the_example_pair_with_a_half_set_pair_names_the_missing_var() { + let err = resolve_from(Some(TEAM_KEY.into()), None, true).unwrap_err(); + assert!( + err.to_string().contains("EXTENDDB_DEV_SECRET_ACCESS_KEY"), + "{err}" + ); + } + + #[test] + fn disable_switch_accepts_on_and_off_values() { + for on in ["1", "true", " 1 "] { + assert!( + parse_switch(ENV_DISABLE_EXAMPLE, Some(on.into())).unwrap(), + "{on}" + ); + } + for off in ["0", "false", " "] { + assert!( + !parse_switch(ENV_DISABLE_EXAMPLE, Some(off.into())).unwrap(), + "{off}" + ); + } + assert!(!parse_switch(ENV_DISABLE_EXAMPLE, None).unwrap()); + } + + #[test] + fn disable_switch_rejects_an_unrecognised_value() { + // A typo must not leave the public credential quietly active. + for bad in ["yes", "on", "TRUE", "2"] { + let err = parse_switch(ENV_DISABLE_EXAMPLE, Some(bad.into())).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(ENV_DISABLE_EXAMPLE), "{msg}"); + assert!(msg.contains(bad), "{msg}"); + } + } + #[test] fn half_set_pair_is_rejected_naming_the_missing_var() { - let err = resolve_from(Some("AKIADEVTEAMKEY000001".into()), None).unwrap_err(); + let err = resolve_from(Some(TEAM_KEY.into()), None, false).unwrap_err(); assert!( err.to_string().contains("EXTENDDB_DEV_SECRET_ACCESS_KEY"), "{err}" ); - let err = resolve_from(None, Some("team-secret".into())).unwrap_err(); + let err = resolve_from(None, Some("team-secret".into()), false).unwrap_err(); assert!( err.to_string().contains("EXTENDDB_DEV_ACCESS_KEY_ID"), "{err}" @@ -320,29 +444,29 @@ mod tests { #[test] fn non_akia_key_is_rejected_pointing_at_the_builtin_pair() { - let err = resolve_from(Some("test".into()), Some("test".into())).unwrap_err(); + let err = resolve_from(Some("test".into()), Some("test".into()), false).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("AKIA-shaped"), "{msg}"); assert!(msg.contains(EXAMPLE_ACCESS_KEY_ID), "{msg}"); } #[test] - fn builtin_key_id_collision_is_rejected() { - let err = resolve_from( - Some(EXAMPLE_ACCESS_KEY_ID.into()), - Some("different-secret".into()), - ) - .unwrap_err(); - assert!(err.to_string().contains("always seeded"), "{err}"); + fn builtin_key_id_collision_is_rejected_whether_or_not_it_is_disabled() { + for disable in [false, true] { + let err = resolve_from( + Some(EXAMPLE_ACCESS_KEY_ID.into()), + Some("different-secret".into()), + disable, + ) + .unwrap_err(); + assert!(err.to_string().contains("reserved"), "{err}"); + } } #[test] fn aws_env_key_is_flagged_only_when_foreign() { - let creds = resolve_from( - Some("AKIADEVTEAMKEY000001".into()), - Some("team-secret".into()), - ) - .unwrap(); + let (k, s) = team_pair(); + let creds = resolve_from(k, s, false).unwrap(); // Unset or empty: nothing to warn about. assert_eq!(ignored_aws_env_key_from(None, &creds), None); // Matches a seeded credential (either of them): no warning. @@ -351,7 +475,7 @@ mod tests { None ); assert_eq!( - ignored_aws_env_key_from(Some("AKIADEVTEAMKEY000001".into()), &creds), + ignored_aws_env_key_from(Some(TEAM_KEY.into()), &creds), None ); // Foreign key, including ASIA session keys: flagged for the advisory. @@ -361,16 +485,34 @@ mod tests { ); } + #[test] + fn aws_env_example_key_is_flagged_once_the_example_pair_is_disabled() { + let (k, s) = team_pair(); + let creds = resolve_from(k, s, true).unwrap(); + assert_eq!( + ignored_aws_env_key_from(Some(EXAMPLE_ACCESS_KEY_ID.into()), &creds), + Some(EXAMPLE_ACCESS_KEY_ID.into()) + ); + } + #[test] fn describe_prints_builtin_secret_but_never_custom_secret() { - let creds = resolve_from( - Some("AKIADEVTEAMKEY000001".into()), - Some("team-secret".into()), - ) - .unwrap(); + let (k, s) = team_pair(); + let creds = resolve_from(k, s, false).unwrap(); let text = describe(&creds); assert!(text.contains(EXAMPLE_SECRET_ACCESS_KEY), "{text}"); - assert!(text.contains("AKIADEVTEAMKEY000001"), "{text}"); + assert!(text.contains(ENV_DISABLE_EXAMPLE), "{text}"); + assert!(text.contains(TEAM_KEY), "{text}"); assert!(!text.contains("team-secret"), "{text}"); } + + #[test] + fn describe_omits_the_example_pair_once_disabled() { + let (k, s) = team_pair(); + let creds = resolve_from(k, s, true).unwrap(); + let text = describe(&creds); + assert!(!text.contains(EXAMPLE_ACCESS_KEY_ID), "{text}"); + assert!(!text.contains(EXAMPLE_SECRET_ACCESS_KEY), "{text}"); + assert!(text.contains(TEAM_KEY), "{text}"); + } } diff --git a/crates/server/src/serve.rs b/crates/server/src/serve.rs index dc4acc2f4..2705a877a 100644 --- a/crates/server/src/serve.rs +++ b/crates/server/src/serve.rs @@ -434,11 +434,16 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> { crate::dev_credentials::describe(&dev_creds), ); if let Some(ignored) = crate::dev_credentials::ignored_aws_env_key(&dev_creds) { + let alternative = if crate::dev_credentials::example_pair_active(&dev_creds) { + "or sign with the built-in example pair" + } else { + "or sign with the configured EXTENDDB_DEV_ACCESS_KEY_ID pair" + }; tracing::warn!( "AWS_ACCESS_KEY_ID is set in the environment but dev mode does not adopt \ it; requests signed with '{ignored}' will get UnrecognizedClientException. \ Set EXTENDDB_DEV_ACCESS_KEY_ID and EXTENDDB_DEV_SECRET_ACCESS_KEY to seed \ - an additional credential, or sign with the built-in example pair." + an additional credential, {alternative}." ); } } diff --git a/docker/dockerhub-overview-dev.md b/docker/dockerhub-overview-dev.md index 6d8bc1896..6b483c600 100644 --- a/docker/dockerhub-overview-dev.md +++ b/docker/dockerhub-overview-dev.md @@ -1,5 +1,7 @@ # ExtendDB dev +> **Local development and CI only. Not for shared networks.** This image serves plain HTTP, its authorization is open, and by default it accepts AWS's public example credential, so anyone who can reach its port can read, change and delete every table. Always publish it on loopback, `-p 127.0.0.1:18080:18080`; a bare `-p 18080:18080` publishes it on every network the host is on. Do not keep real data in it. On a shared host or CI runner, also set `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1` with your own `EXTENDDB_DEV_ACCESS_KEY_ID` and `EXTENDDB_DEV_SECRET_ACCESS_KEY` ([details](https://github.com/ExtendDB/extenddb/blob/main/docs/dev-image.md#a-shared-host-or-ci-runner)). + ExtendDB dev is a single-container, DynamoDB-compatible endpoint that enables developers to develop and test applications against the DynamoDB API in their own development environment. ## Benefits of using ExtendDB dev @@ -53,10 +55,6 @@ cosign verify \ The routine signer is `release-dev-image.yml`; the break-glass signer `sign-image.yml` can sign any tier, so drop it from the pattern to require the routine dev path. -## Note - -This image is built for local development and CI. It serves plain HTTP and its authorization is open, so whoever holds the credential can do anything. Publish it to loopback only, as the command above does, and do not keep real data in it. - ExtendDB is an independent open source project managed by Amazon Web Services. It is not Amazon DynamoDB and does not contain any DynamoDB source code. "DynamoDB" is a trademark of Amazon.com, Inc. ExtendDB is a clean-room implementation that speaks the DynamoDB wire protocol; behavioral differences from the service are documented in [Differences from DynamoDB](https://github.com/ExtendDB/extenddb/blob/main/docs/differences-from-dynamodb.md). More at [extenddb.org](https://extenddb.org) and [github.com/ExtendDB/extenddb](https://github.com/ExtendDB/extenddb). Licensed under Apache-2.0. diff --git a/docker/dockerhub-overview-mongodb.md b/docker/dockerhub-overview-mongodb.md index 246dfb526..0002fd234 100644 --- a/docker/dockerhub-overview-mongodb.md +++ b/docker/dockerhub-overview-mongodb.md @@ -44,7 +44,7 @@ Start the server using the generated configuration: ```console docker run --rm --name extenddb-mongodb \ - -p 18443:18443 \ + -p 127.0.0.1:18443:18443 \ -v "$PWD/extenddb-data:/var/lib/extenddb" \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ diff --git a/docs/dev-image.md b/docs/dev-image.md index c7ae5c967..9f8186457 100644 --- a/docs/dev-image.md +++ b/docs/dev-image.md @@ -1,5 +1,13 @@ # The `extenddb-dev` Image +> **Local development and CI only.** The dev image serves plain HTTP, its +> authorization is open, and by default it accepts AWS's public example +> credential. Anyone who can reach its port can read, change and delete every +> table. Always publish it on loopback, `-p 127.0.0.1:18080:18080`; a bare +> `-p 18080:18080` publishes it on every network the host is on. Never run it +> on a shared network or keep real data in it. On a shared host or CI runner, +> also [remove the example credential](#a-shared-host-or-ci-runner). + `extenddb/extenddb-dev` is a single-container, DynamoDB-compatible endpoint over SQLite, built for local development and CI. There is no external database, no init step, and no certificate to trust: @@ -27,14 +35,16 @@ zero-friction local use: - **Plain HTTP.** No TLS, so SDKs need no custom trust store. - **A seeded, well-known credential.** The server seeds AWS's documented example key pair (`AKIAIOSFODNN7EXAMPLE` / - `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) on every startup and prints it + `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) on every startup by default and + prints it in the logs; sign requests with it. It is recognised by secret scanners as an example credential, so committing it in test fixtures is safe. To seed one additional key pair, pass `EXTENDDB_DEV_ACCESS_KEY_ID` and `EXTENDDB_DEV_SECRET_ACCESS_KEY` to the container (the key id must start with `AKIA`; its secret is never printed). The example pair keeps working alongside it, so tools that hardcode it (NoSQL Workbench, the launchers) - are unaffected by the override. The standard `AWS_*` variables are ignored + are unaffected by the override, unless you remove the example pair (see + below). The standard `AWS_*` variables are ignored by the server, so real or SSO credentials in the environment are never copied into the dev catalog; a startup notice calls out an `AWS_ACCESS_KEY_ID` that would not verify. Authorization is open: whoever @@ -45,7 +55,39 @@ image binds `0.0.0.0` **inside** the container (required for port publishing to work at all; this is what `EXTENDDB_DEV_ALLOW_ANY_BIND=1` in the image enables), and containment moves to the publish flag: always bind the host side to loopback, `-p 127.0.0.1:18080:18080`, as every example here does. Do not -publish this port on a shared or routable interface. +publish this port on a shared or routable interface. The server cannot see the +host's port mapping, so whenever it binds a non-loopback address, as it does in +the image, it prints a warning block at startup with the loopback publish +command. + +### A shared host or CI runner + +Where other people or machines can reach the host (a shared build machine, a +CI runner with a routable address), remove the public example credential and +use your own: + +```bash +docker run -d -p 127.0.0.1:18080:18080 \ + -e EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1 \ + -e EXTENDDB_DEV_ACCESS_KEY_ID=AKIA \ + -e EXTENDDB_DEV_SECRET_ACCESS_KEY= \ + extenddb/extenddb-dev +``` + +With `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1` (or `true`), the +`EXTENDDB_DEV_*` pair is the only credential the server accepts: + +- The server refuses to start unless both `EXTENDDB_DEV_*` variables are set, + so it never runs with no credential, and it refuses a value other than `1`, + `true`, `0` or `false`, so a typo does not leave the example pair active. +- On a file-backed database, an example key stored by an earlier start is + removed, so it stops working. Unset the variable to bring it back on the + next start. +- Tools that hardcode the example pair (NoSQL Workbench, the launchers) must + be pointed at your credential instead. + +This narrows who can sign requests; it does not add TLS or permissions. Keep +the loopback publish. ## Storage modes From 15674a24524e54ffc95d6b8e7a2c6d1a50987b1f Mon Sep 17 00:00:00 2001 From: Hitesh Sharma Date: Fri, 6 Nov 2026 11:28:30 -0800 Subject: [PATCH 2/2] fix(dev-mode): Remove the example key from any user Address review feedback on the example-credential switch. - Remove the example key from whichever user holds it, found through the credential store, not only from the dev user. A copy imported for another user with extenddb manage kept verifying after the switch. - Refuse an empty EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL. With -e NAME=$VAR and VAR unset, the empty value read as off and left the public key active. - Test the removal against one SQLite catalog file across starts: a key the default start seeded, a key imported for another user, and a start without the switch that keeps it. - Drop the non-loopback tracing::warn! in serve, which ran before any subscriber was installed and printed nothing. The dev banner carries the same warning. - docs/dev-image.md: state what the switch removes and which stored keys keep working, mention the empty value, and rewrap a paragraph. --- Cargo.lock | 2 + crates/app/src/cmd_serve.rs | 14 +- crates/server/Cargo.toml | 3 + crates/server/src/dev_credentials.rs | 107 +++++++--- .../server/src/dev_credentials_seed_tests.rs | 194 ++++++++++++++++++ docs/dev-image.md | 44 ++-- 6 files changed, 310 insertions(+), 54 deletions(-) create mode 100644 crates/server/src/dev_credentials_seed_tests.rs diff --git a/Cargo.lock b/Cargo.lock index b836d650e..29d03fa3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,6 +1806,7 @@ dependencies = [ "extenddb-core", "extenddb-engine", "extenddb-storage", + "extenddb-storage-sqlite", "futures", "hyper", "libc", @@ -1815,6 +1816,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "sqlx", "syslog-tracing", "tempfile", "time", diff --git a/crates/app/src/cmd_serve.rs b/crates/app/src/cmd_serve.rs index 2b22a331e..fcca70404 100755 --- a/crates/app/src/cmd_serve.rs +++ b/crates/app/src/cmd_serve.rs @@ -125,16 +125,10 @@ pub fn run(args: &ServeArgs, build: BuildInfo) -> anyhow::Result<()> { inside a container, where the published port decides exposure." ); } - if !loopback && allow_any_bind { - tracing::warn!( - bind_addr = host, - "dev-mode is binding a non-loopback address: plain HTTP with relaxed \ - authorization. Publish it on loopback only (-p 127.0.0.1:PORT:PORT) and \ - never expose it to a shared network. On a shared host, also set \ - EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1 with your own \ - EXTENDDB_DEV_ACCESS_KEY_ID / EXTENDDB_DEV_SECRET_ACCESS_KEY." - ); - } + // A permitted non-loopback bind is warned about in the dev-mode banner + // (`dev_mode_banner_lines`), which prints to the console. A + // `tracing::warn!` here would print nothing: no subscriber is installed + // until `serve_inner` runs. } let bind_addr = format!("{}:{}", app_config.server.bind_addr, port); diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index e674e5c58..3a85b2321 100755 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -52,5 +52,8 @@ libc = { workspace = true } [dev-dependencies] extenddb-cache = { workspace = true, features = ["test-util"] } +# The dev-credential seeding tests run against a SQLite catalog file. +extenddb-storage-sqlite = { workspace = true } +sqlx = { workspace = true, features = ["sqlite"] } tempfile = "3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/server/src/dev_credentials.rs b/crates/server/src/dev_credentials.rs index adeffb7a2..ce69039dd 100644 --- a/crates/server/src/dev_credentials.rs +++ b/crates/server/src/dev_credentials.rs @@ -23,9 +23,9 @@ //! dev server that is reachable by others (a shared host, a CI runner with a //! routable address): the public key would otherwise open every table to //! anyone who can reach the port. It requires the `EXTENDDB_DEV_*` pair, so -//! the server always has a credential to verify, and it removes an example -//! key an earlier boot stored in a file-backed catalog, so the key stops -//! verifying rather than lingering. +//! the server always has a credential to verify, and it removes the example +//! key from whichever user an earlier boot or an operator stored it on in a +//! file-backed catalog, so the key stops verifying rather than lingering. //! * The standard `AWS_*` variables are never read for seeding. Adopting them //! meant a shell holding real long-lived credentials silently copied them //! into the dev catalog, a shell holding SSO session credentials (`ASIA*`) @@ -102,7 +102,9 @@ pub fn resolve() -> anyhow::Result> { resolve_from( non_empty_env(ENV_ACCESS_KEY_ID), non_empty_env(ENV_SECRET_ACCESS_KEY), - parse_switch(ENV_DISABLE_EXAMPLE, non_empty_env(ENV_DISABLE_EXAMPLE))?, + // Read as-is, not through `non_empty_env`: an empty value is refused + // rather than read as unset (see `parse_switch`). + parse_switch(ENV_DISABLE_EXAMPLE, std::env::var(ENV_DISABLE_EXAMPLE).ok())?, ) } @@ -110,16 +112,23 @@ fn non_empty_env(name: &str) -> Option { std::env::var(name).ok().filter(|s| !s.is_empty()) } -/// Parse an on/off switch. Unset or empty is off. An unrecognised value is an -/// error rather than "off": this switch removes a public credential, so a typo -/// such as `yes` must not leave the server quietly accepting it. +/// Parse an on/off switch. Unset is off. An unrecognised value is an error +/// rather than "off": this switch removes a public credential, so a typo such +/// as `yes` must not leave the server quietly accepting it. An empty value is +/// refused for the same reason: `-e NAME=$VAR` with `VAR` unset sets it empty, +/// and reading that as "off" would leave the public credential active when +/// the operator asked to remove it. fn parse_switch(name: &str, value: Option) -> anyhow::Result { let Some(value) = value else { return Ok(false); }; match value.trim() { "1" | "true" => Ok(true), - "0" | "false" | "" => Ok(false), + "0" | "false" => Ok(false), + "" => anyhow::bail!( + "{name} is set but empty; set it to 1 or true to remove the example \ + credential, 0 or false to keep it, or unset it." + ), other => anyhow::bail!("{name} must be 1, true, 0 or false (got '{other}')."), } } @@ -286,24 +295,54 @@ pub async fn seed( } } - // The example pair is disabled: remove it if an earlier boot stored it in - // a file-backed catalog. Seeding only adds keys, so without this the public - // key would keep verifying on every restart after the switch was set. + // The example pair is disabled: remove it if an earlier boot, or an + // operator through the management API, stored it in a file-backed catalog. + // Seeding only adds keys, so without this the public key would keep + // verifying on every restart after the switch was set. if !example_pair_active(credentials) { - match catalog_store - .delete_access_key(&account_id, "dev", EXAMPLE_ACCESS_KEY_ID) - .await - { - Ok(()) => tracing::info!( - "dev credential {EXAMPLE_ACCESS_KEY_ID}: removed from the catalog \ - ({ENV_DISABLE_EXAMPLE} is set)" - ), - Err(OpError::NotFound(_)) => {} - Err(e) => anyhow::bail!( - "dev mode: failed to remove the example credential \ - {EXAMPLE_ACCESS_KEY_ID}: {e:?}" - ), - } + remove_example_key(catalog_store, credential_store).await?; + } + Ok(()) +} + +/// Delete the example access key from whichever user holds it. +/// +/// The key id is public, so a copy on any user opens the same door: the one +/// dev mode seeds lives on the `dev` user, but an operator can import the pair +/// for another user with `extenddb manage`. The owner comes from the +/// credential store, which looks keys up by id alone. It is the uncached +/// store: the auth cache is built after seeding, so this lookup leaves nothing +/// cached behind the delete. +async fn remove_example_key( + catalog_store: &dyn CatalogStore, + credential_store: &dyn extenddb_auth::CredentialStore, +) -> anyhow::Result<()> { + let stored = credential_store + .lookup_credential(EXAMPLE_ACCESS_KEY_ID) + .await + .map_err(|e| { + anyhow::anyhow!( + "dev mode: failed to look up the example credential {EXAMPLE_ACCESS_KEY_ID}: {e:?}" + ) + })?; + let Some(stored) = stored else { + return Ok(()); + }; + let (account_id, user_name) = (&stored.account_id, &stored.principal_name); + match catalog_store + .delete_access_key(account_id, user_name, EXAMPLE_ACCESS_KEY_ID) + .await + { + Ok(()) => tracing::info!( + "dev credential {EXAMPLE_ACCESS_KEY_ID}: removed from user \ + {account_id}/{user_name} ({ENV_DISABLE_EXAMPLE} is set)" + ), + // Deleted between the lookup and the delete: already gone. + Err(OpError::NotFound(_)) => {} + Err(e) => anyhow::bail!( + "dev mode: failed to remove the example credential {EXAMPLE_ACCESS_KEY_ID} \ + from user {account_id}/{user_name}: {e:?}" + ), } Ok(()) } @@ -408,7 +447,7 @@ mod tests { "{on}" ); } - for off in ["0", "false", " "] { + for off in ["0", "false", " 0 "] { assert!( !parse_switch(ENV_DISABLE_EXAMPLE, Some(off.into())).unwrap(), "{off}" @@ -417,6 +456,18 @@ mod tests { assert!(!parse_switch(ENV_DISABLE_EXAMPLE, None).unwrap()); } + #[test] + fn disable_switch_rejects_an_empty_value() { + // `-e NAME=$VAR` with VAR unset: the operator asked for the switch, + // so an empty value must not quietly leave the public key active. + for empty in ["", " "] { + let err = parse_switch(ENV_DISABLE_EXAMPLE, Some(empty.into())).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains(ENV_DISABLE_EXAMPLE), "{msg}"); + assert!(msg.contains("empty"), "{msg}"); + } + } + #[test] fn disable_switch_rejects_an_unrecognised_value() { // A typo must not leave the public credential quietly active. @@ -516,3 +567,7 @@ mod tests { assert!(text.contains(TEAM_KEY), "{text}"); } } + +#[cfg(test)] +#[path = "dev_credentials_seed_tests.rs"] +mod seed_tests; diff --git a/crates/server/src/dev_credentials_seed_tests.rs b/crates/server/src/dev_credentials_seed_tests.rs new file mode 100644 index 000000000..7d952cf5e --- /dev/null +++ b/crates/server/src/dev_credentials_seed_tests.rs @@ -0,0 +1,194 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests that seed one SQLite catalog file across several starts, as a dev +//! container does when it restarts on a reused volume. +//! +//! Each start opens the file again, so a test sees what the catalog kept from +//! the previous start, not what one connection still holds. + +use std::path::Path; +use std::sync::Arc; + +use base64::Engine as _; +use extenddb_auth::CredentialStore; +use extenddb_storage::management_store::ManagementStore; +use extenddb_storage::management_store::SettingsStore; +use extenddb_storage_sqlite::migrations::SqliteMigrations; +use extenddb_storage_sqlite::{SqliteCatalogStore, SqliteCredentialStore}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +use super::{EXAMPLE_ACCESS_KEY_ID, EXAMPLE_SECRET_ACCESS_KEY, resolve_from, seed}; + +const ACCOUNT: &str = "123456789012"; +const TEAM_KEY: &str = "AKIATEAMEXAMPLE00001"; +const TEAM_SECRET: &str = "team-secret"; + +/// The catalog and credential stores of one start, over `path`. +struct Start { + catalog: SqliteCatalogStore, + credentials: SqliteCredentialStore, +} + +/// Open the catalog file at `path`, creating and migrating it on the first +/// start, with `ACCOUNT` as the default account. +async fn start(path: &Path) -> Start { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .foreign_keys(true); + // The migration runner takes a one-connection pool. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("open the catalog file"); + let migrations = + SqliteMigrations::on_pool(pool.clone(), &path.display().to_string()).expect("one pool"); + extenddb_storage::migrations::Runner::new(&migrations, "test") + .migrate() + .await + .expect("migrate the catalog"); + + // Any fixed 32-byte key: the two stores only have to agree on it. + let key = base64::engine::general_purpose::STANDARD.encode([7u8; 32]); + let catalog = SqliteCatalogStore::with_encryption_key( + pool.clone(), + key.clone(), + Arc::new(tokio::sync::Mutex::new(())), + ); + match catalog.create_account(ACCOUNT, "dev").await { + Ok(()) | Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => {} + Err(e) => panic!("create the default account: {e:?}"), + } + catalog + .set_setting("default_account_id", ACCOUNT) + .await + .expect("record the default account"); + Start { + catalog, + credentials: SqliteCredentialStore::new(pool, key), + } +} + +/// Seed one start with the credentials the given environment resolves to. +async fn seed_start(s: &Start, team: bool, disable_example: bool) { + let (key, secret) = if team { + (Some(TEAM_KEY.to_owned()), Some(TEAM_SECRET.to_owned())) + } else { + (None, None) + }; + let credentials = resolve_from(key, secret, disable_example).expect("resolve"); + seed(&s.catalog, &s.credentials, &credentials) + .await + .expect("seed"); +} + +/// The account and user that hold `key_id`, or `None` when no user does. +async fn owner(s: &Start, key_id: &str) -> Option<(String, String)> { + s.credentials + .lookup_credential(key_id) + .await + .expect("look the key up") + .map(|c| (c.account_id.clone(), c.principal_name.clone())) +} + +#[tokio::test] +async fn the_switch_removes_the_example_key_an_earlier_start_seeded() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.sqlite"); + + // First start: the default, which seeds the example pair on `dev`. + let first = start(&path).await; + seed_start(&first, false, false).await; + let stored = first + .credentials + .lookup_credential(EXAMPLE_ACCESS_KEY_ID) + .await + .unwrap() + .expect("the example key is seeded by default"); + assert_eq!(stored.secret_key, EXAMPLE_SECRET_ACCESS_KEY); + assert_eq!(stored.principal_name, "dev"); + drop(first); + + // Second start on the same file: the switch and a team pair. + let second = start(&path).await; + seed_start(&second, true, true).await; + assert_eq!(owner(&second, EXAMPLE_ACCESS_KEY_ID).await, None); + assert_eq!( + owner(&second, TEAM_KEY).await, + Some((ACCOUNT.to_owned(), "dev".to_owned())) + ); + drop(second); + + // Third start with the switch: nothing left to remove, and no error. + let third = start(&path).await; + seed_start(&third, true, true).await; + assert_eq!(owner(&third, EXAMPLE_ACCESS_KEY_ID).await, None); + assert!(owner(&third, TEAM_KEY).await.is_some()); +} + +#[tokio::test] +async fn the_switch_removes_the_example_key_from_whichever_user_holds_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.sqlite"); + + // A start with the switch, then an operator imports the public pair for + // another user, as `extenddb manage import-access-key` does. + let first = start(&path).await; + seed_start(&first, true, true).await; + first + .catalog + .create_user(ACCOUNT, "alice", None) + .await + .unwrap(); + first + .catalog + .import_access_key( + ACCOUNT, + "alice", + EXAMPLE_ACCESS_KEY_ID, + EXAMPLE_SECRET_ACCESS_KEY, + ) + .await + .unwrap(); + assert_eq!( + owner(&first, EXAMPLE_ACCESS_KEY_ID).await, + Some((ACCOUNT.to_owned(), "alice".to_owned())) + ); + drop(first); + + // The next start with the switch removes it from `alice`, and leaves + // `alice` and the team key in place. + let second = start(&path).await; + seed_start(&second, true, true).await; + assert_eq!(owner(&second, EXAMPLE_ACCESS_KEY_ID).await, None); + assert!( + second + .catalog + .get_user_detail(ACCOUNT, "alice") + .await + .unwrap() + .is_some(), + "the user that held the key is kept" + ); + assert!(owner(&second, TEAM_KEY).await.is_some()); +} + +#[tokio::test] +async fn without_the_switch_the_example_key_is_kept() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("catalog.sqlite"); + + let first = start(&path).await; + seed_start(&first, true, false).await; + drop(first); + + let second = start(&path).await; + seed_start(&second, true, false).await; + assert_eq!( + owner(&second, EXAMPLE_ACCESS_KEY_ID).await, + Some((ACCOUNT.to_owned(), "dev".to_owned())) + ); + assert!(owner(&second, TEAM_KEY).await.is_some()); +} diff --git a/docs/dev-image.md b/docs/dev-image.md index 9f8186457..8002a0c0d 100644 --- a/docs/dev-image.md +++ b/docs/dev-image.md @@ -36,19 +36,18 @@ zero-friction local use: - **A seeded, well-known credential.** The server seeds AWS's documented example key pair (`AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) on every startup by default and - prints it - in the logs; sign requests with it. It is recognised by secret scanners as - an example credential, so committing it in test fixtures is safe. To seed - one additional key pair, pass `EXTENDDB_DEV_ACCESS_KEY_ID` and + prints it in the logs; sign requests with it. It is recognised by secret + scanners as an example credential, so committing it in test fixtures is + safe. To seed one additional key pair, pass `EXTENDDB_DEV_ACCESS_KEY_ID` and `EXTENDDB_DEV_SECRET_ACCESS_KEY` to the container (the key id must start with `AKIA`; its secret is never printed). The example pair keeps working alongside it, so tools that hardcode it (NoSQL Workbench, the launchers) are unaffected by the override, unless you remove the example pair (see - below). The standard `AWS_*` variables are ignored - by the server, so real or SSO credentials in the environment are never - copied into the dev catalog; a startup notice calls out an - `AWS_ACCESS_KEY_ID` that would not verify. Authorization is open: whoever - holds a seeded credential can do everything. + below). The standard `AWS_*` variables are ignored by the server, so real + or SSO credentials in the environment are never copied into the dev + catalog; a startup notice calls out an `AWS_ACCESS_KEY_ID` that would not + verify. Authorization is open: whoever holds a seeded credential can do + everything. Because of that, the server must not be reachable from other machines. The image binds `0.0.0.0` **inside** the container (required for port publishing to @@ -74,18 +73,27 @@ docker run -d -p 127.0.0.1:18080:18080 \ extenddb/extenddb-dev ``` -With `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1` (or `true`), the -`EXTENDDB_DEV_*` pair is the only credential the server accepts: - -- The server refuses to start unless both `EXTENDDB_DEV_*` variables are set, - so it never runs with no credential, and it refuses a value other than `1`, - `true`, `0` or `false`, so a typo does not leave the example pair active. -- On a file-backed database, an example key stored by an earlier start is - removed, so it stops working. Unset the variable to bring it back on the - next start. +With `EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=1` (or `true`), the server stops +accepting the example pair: + +- It does not seed the example pair, and on a file-backed database it deletes + the example key from whichever user holds it: the `dev` user an earlier + start seeded, or a user you imported it for with `extenddb manage`. Unset + the variable to seed it again on the next start. +- It refuses to start unless both `EXTENDDB_DEV_*` variables are set, so it + never runs with no credential to sign with. +- It refuses to start for a value other than `1`, `true`, `0` or `false`, + including an empty value, so a typo or a `-e + EXTENDDB_DEV_DISABLE_EXAMPLE_CREDENTIAL=$VAR` with `VAR` unset does not + leave the example pair active. - Tools that hardcode the example pair (NoSQL Workbench, the launchers) must be pointed at your credential instead. +The switch removes only the example key. Other keys a file-backed database +holds keep working: an `EXTENDDB_DEV_*` key from an earlier start that you +have since changed, and keys added with `extenddb manage`. To start with only +the current `EXTENDDB_DEV_*` pair, recreate the volume. + This narrows who can sign requests; it does not add TLS or permissions. Keep the loopback publish.