Skip to content
Merged
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
21 changes: 21 additions & 0 deletions crates/database/src/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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)
}
}

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion crates/public-server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
},
Expand Down
24 changes: 15 additions & 9 deletions crates/public-server/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
)))
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions crates/public-server/tests/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<sql_types::Uuid, _>(server_id)
.bind::<sql_types::Text, _>(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::<sql_types::Uuid, _>(server_id)
.bind::<sql_types::Text, _>(r#type)
.bind::<sql_types::Text, _>(purpose)
.execute(conn)
.await
.expect("insert backup request");
}

// --- 412 / 409 / 502 resolution matrix --------------------------------------

#[tokio::test(flavor = "multi_thread")]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down