diff --git a/crates/database/src/backups.rs b/crates/database/src/backups.rs index e21a94d7..ae92acc1 100644 --- a/crates/database/src/backups.rs +++ b/crates/database/src/backups.rs @@ -1331,6 +1331,27 @@ impl BackupRequest { .await .map_err(AppError::from) } + + /// Whether a one-off request is pending for `(server, type, purpose)`. + pub async fn exists( + db: &mut AsyncPgConnection, + server_id: Uuid, + r#type: &BackupType, + purpose: BackupPurpose, + ) -> Result { + use crate::schema::backup_requests::dsl; + use diesel::dsl::{exists, select}; + + select(exists( + dsl::backup_requests + .filter(dsl::server_id.eq(server_id)) + .filter(dsl::type_.eq(r#type.as_str())) + .filter(dsl::purpose.eq(purpose)), + )) + .get_result(db) + .await + .map_err(AppError::from) + } } // --------------------------------------------------------------------------- diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index fc20b205..b468fcc4 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -1174,7 +1174,7 @@ }, "type": { "type": "string", - "description": "The backup type these creds are for. Must be an enabled capability." + "description": "The backup type these creds are for. Must be an enabled capability or\nhave a pending request of this `purpose` (an on-demand backup/restore)." } } }, diff --git a/crates/public-server/src/backup.rs b/crates/public-server/src/backup.rs index bce6de23..f0f326e0 100644 --- a/crates/public-server/src/backup.rs +++ b/crates/public-server/src/backup.rs @@ -13,8 +13,9 @@ //! //! All four resolve `device → live server → group_id → group backup config` //! identically: **412** when the device is bound to no live server, **409** -//! when the server is ungrouped / has no `ready` config / the type isn't an -//! enabled capability, **502** when STS or kube fails or isn't configured. +//! when the server is ungrouped / has no `ready` config / the type is neither +//! an enabled capability nor has a pending request, **502** when STS or kube +//! fails or isn't configured. use aws_sdk_sts::operation::RequestId as _; use axum::{Json, extract::State, http::StatusCode}; @@ -101,22 +102,26 @@ async fn require_ready_config( Ok(cfg) } -/// Require that `(server, type)` is an enabled capability — the per-`(server, -/// type)` issuance gate. Not enabled / not registered ⇒ 409. -async fn require_enabled_capability( +/// The per-`(server, type)` credential-issuance gate. Mirrors what the +/// heartbeat is willing to ask the device to run: a type may be issued creds +/// when it's an enabled capability (on the auto-schedule), or when an on-demand +/// request of this `purpose` is pending (an operator "backup now" / restore). +/// Neither ⇒ 409. +async fn require_issuable_capability( conn: &mut database::diesel_async::AsyncPgConnection, server_id: Uuid, r#type: &BackupType, + purpose: BackupPurpose, ) -> Result<()> { let enabled = ServerBackupCapability::list_for_server(conn, server_id) .await? .into_iter() .any(|c| &c.r#type == r#type && c.enabled); - if enabled { + if enabled || BackupRequest::exists(conn, server_id, r#type, purpose).await? { Ok(()) } else { Err(AppError::Conflict(format!( - "backup type {type} is not an enabled capability for this server", + "backup type {type} is not an enabled capability for this server, and no {purpose} is pending", type = r#type ))) } @@ -177,7 +182,8 @@ async fn capabilities( #[derive(Debug, Deserialize, ToSchema)] pub struct CredentialsArgs { - /// The backup type these creds are for. Must be an enabled capability. + /// The backup type these creds are for. Must be an enabled capability or + /// have a pending request of this `purpose` (an on-demand backup/restore). #[schema(value_type = String)] pub r#type: BackupType, /// `backup` (default) grants write-without-delete; `restore` is downscoped @@ -301,7 +307,7 @@ async fn credentials( let server = resolve_server(&mut conn, device_id).await?; let group_id = require_group(&server)?; let cfg = require_ready_config(&mut conn, group_id).await?; - require_enabled_capability(&mut conn, server.id, &args.r#type).await?; + require_issuable_capability(&mut conn, server.id, &args.r#type, args.purpose).await?; // Always attach a bucket-scoped session policy so the issued creds can only // reach this group's bucket — redundant for a dedicated per-bucket role diff --git a/crates/public-server/tests/backup.rs b/crates/public-server/tests/backup.rs index a00e931b..0575aa43 100644 --- a/crates/public-server/tests/backup.rs +++ b/crates/public-server/tests/backup.rs @@ -69,6 +69,34 @@ async fn enable_capability(conn: &mut AsyncPgConnection, server_id: Uuid, r#type .expect("insert capability"); } +/// Register a capability that is declared but *not* on the schedule (`enabled = +/// false`) — only an on-demand request can drive a backup of it. +async fn declare_capability_disabled(conn: &mut AsyncPgConnection, server_id: Uuid, r#type: &str) { + sql_query( + "INSERT INTO server_backup_capabilities (server_id, type, enabled) VALUES ($1, $2, false)", + ) + .bind::(server_id) + .bind::(r#type) + .execute(conn) + .await + .expect("insert disabled capability"); +} + +async fn enqueue_request( + conn: &mut AsyncPgConnection, + server_id: Uuid, + r#type: &str, + purpose: &str, +) { + sql_query("INSERT INTO backup_requests (server_id, type, purpose) VALUES ($1, $2, $3)") + .bind::(server_id) + .bind::(r#type) + .bind::(purpose) + .execute(conn) + .await + .expect("insert backup request"); +} + // --- 412 / 409 / 502 resolution matrix -------------------------------------- #[tokio::test(flavor = "multi_thread")] @@ -163,6 +191,27 @@ async fn credentials_type_not_enabled_is_409() { .await; } +#[tokio::test(flavor = "multi_thread")] +async fn credentials_disabled_capability_no_request_is_409() { + commons_tests::server::run_with_device_auth( + "server", + async |mut conn, cert, device_id, public, _| { + let group = make_group(&mut conn).await; + let server = make_server(&mut conn, device_id, Some(group)).await; + make_config(&mut conn, group, "ready").await; + // Declared but not scheduled, and no pending request → still 409. + declare_capability_disabled(&mut conn, server, "tamanu-config").await; + let resp = public + .post("/backup-credentials") + .add_header("mtls-certificate", &cert) + .json(&serde_json::json!({ "type": "tamanu-config" })) + .await; + resp.assert_status(http::StatusCode::CONFLICT); + }, + ) + .await; +} + #[tokio::test(flavor = "multi_thread")] async fn credentials_ready_but_sts_unconfigured_is_502() { commons_tests::server::run_with_device_auth( @@ -518,6 +567,42 @@ async fn credentials_backup_happy_path_200_and_audit() { .await; } +#[tokio::test(flavor = "multi_thread")] +async fn credentials_disabled_capability_with_pending_request_200() { + use commons_types::backup::BackupPurpose; + use database::BackupCredentialIssuance; + + commons_tests::db::TestDb::run(async |mut conn, url| { + let (device_id, cert) = seed_device(&mut conn, "server").await; + let group = make_group(&mut conn).await; + let server = make_server(&mut conn, device_id, Some(group)).await; + make_config(&mut conn, group, "ready").await; + // A declared-but-not-scheduled type with an operator "backup now" request + // pending: the issuance gate must let it through (this is the bug fix). + declare_capability_disabled(&mut conn, server, "tamanu-config").await; + enqueue_request(&mut conn, server, "tamanu-config", "backup").await; + + let rule = assume_role_rule(Some("arn:aws:s3:::grp-bucket")); + let sts = aws_smithy_mocks::mock_client!(aws_sdk_sts, RuleMode::MatchAny, [&rule]); + let public = public_server_with_sts(&url, sts); + + let resp = public + .post("/backup-credentials") + .add_header("mtls-certificate", &cert) + .json(&serde_json::json!({ "type": "tamanu-config", "purpose": "backup" })) + .await; + resp.assert_status_ok(); + + let issuances = BackupCredentialIssuance::list_for_group(&mut conn, group, 10) + .await + .unwrap(); + assert_eq!(issuances.len(), 1); + assert_eq!(issuances[0].purpose, BackupPurpose::Backup); + assert_eq!(issuances[0].r#type, "tamanu-config".into()); + }) + .await; +} + #[tokio::test(flavor = "multi_thread")] async fn credentials_restore_sends_session_policy() { use commons_types::backup::BackupPurpose;