Implement replay-safe authorized public HTTP API

This commit is contained in:
ms2k 2026-07-17 21:23:18 +09:00
commit 4c93c25692

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use futures_util::TryStreamExt;
use remora_domain::{BucketName, OpaqueId, Target, TargetPrefix, TargetStoreId};
use remora_durable::enqueue;
use remora_durable::{PgTx, enqueue};
use remora_identity::{
AuthorizedResource, ManagementOperation, Principal, ResourceVisibility, resource_visibility,
};
@ -52,14 +52,23 @@ impl DeletionService {
job_id: &OpaqueId,
) -> Result<LifecycleMutation, DeletionError> {
let mut tx = self.pool.begin().await?;
let row = lock_job(&mut tx, job_id).await?;
let outcome = Self::soft_delete_in_tx(&mut tx, principal, job_id).await?;
tx.commit().await?;
Ok(outcome)
}
pub async fn soft_delete_in_tx(
tx: &mut PgTx<'_>,
principal: &Principal,
job_id: &OpaqueId,
) -> Result<LifecycleMutation, DeletionError> {
let row = lock_job(tx, job_id).await?;
authorize(principal, &row, ManagementOperation::SoftDelete)?;
require_terminal(&row.phase)?;
if row.deletion_operation_id.is_some() {
return Err(DeletionError::HardDeleteStarted);
}
if row.lifecycle == "soft_deleted" {
tx.commit().await?;
return Ok(LifecycleMutation::Unchanged);
}
let now = OffsetDateTime::now_utc();
@ -72,8 +81,7 @@ impl DeletionService {
.bind(now)
.execute(tx.as_mut())
.await?;
enqueue(&mut tx, "job.updated", json!({ "job_id": job_id })).await?;
tx.commit().await?;
enqueue(tx, "job.updated", json!({ "job_id": job_id })).await?;
Ok(LifecycleMutation::Changed)
}
@ -83,14 +91,23 @@ impl DeletionService {
job_id: &OpaqueId,
) -> Result<LifecycleMutation, DeletionError> {
let mut tx = self.pool.begin().await?;
let row = lock_job(&mut tx, job_id).await?;
let outcome = Self::restore_in_tx(&mut tx, principal, job_id).await?;
tx.commit().await?;
Ok(outcome)
}
pub async fn restore_in_tx(
tx: &mut PgTx<'_>,
principal: &Principal,
job_id: &OpaqueId,
) -> Result<LifecycleMutation, DeletionError> {
let row = lock_job(tx, job_id).await?;
authorize(principal, &row, ManagementOperation::Restore)?;
require_terminal(&row.phase)?;
if row.deletion_operation_id.is_some() {
return Err(DeletionError::HardDeleteStarted);
}
if row.lifecycle == "visible" {
tx.commit().await?;
return Ok(LifecycleMutation::Unchanged);
}
let now = OffsetDateTime::now_utc();
@ -103,8 +120,7 @@ impl DeletionService {
.bind(now)
.execute(tx.as_mut())
.await?;
enqueue(&mut tx, "job.updated", json!({ "job_id": job_id })).await?;
tx.commit().await?;
enqueue(tx, "job.updated", json!({ "job_id": job_id })).await?;
Ok(LifecycleMutation::Changed)
}
@ -113,21 +129,30 @@ impl DeletionService {
principal: &Principal,
job_id: &OpaqueId,
) -> Result<HardDeleteOutcome, DeletionError> {
let operation_id = new_id('d')?;
let mut tx = self.pool.begin().await?;
let row = lock_job(&mut tx, job_id).await?;
let outcome = Self::hard_delete_in_tx(&mut tx, principal, job_id).await?;
tx.commit().await?;
Ok(outcome)
}
pub async fn hard_delete_in_tx(
tx: &mut PgTx<'_>,
principal: &Principal,
job_id: &OpaqueId,
) -> Result<HardDeleteOutcome, DeletionError> {
let operation_id = new_id('d')?;
let row = lock_job(tx, job_id).await?;
authorize(principal, &row, ManagementOperation::HardDelete)?;
require_terminal(&row.phase)?;
if let Some(existing) = row.deletion_operation_id {
let operation = load_operation_in_tx(&mut tx, &existing).await?;
tx.commit().await?;
let operation = load_operation_in_tx(tx, &existing).await?;
return Ok(HardDeleteOutcome {
mutation: LifecycleMutation::Unchanged,
operation,
});
}
validate_committed_inventory(&mut tx, &row).await?;
validate_deletion_identities(&mut tx, &row).await?;
validate_committed_inventory(tx, &row).await?;
validate_deletion_identities(tx, &row).await?;
let now = OffsetDateTime::now_utc();
sqlx::query(
"INSERT INTO deletion_operations (
@ -150,16 +175,15 @@ impl DeletionService {
.bind(now)
.execute(tx.as_mut())
.await?;
materialize_items(&mut tx, job_id, &operation_id, now).await?;
finalize_if_empty(&mut tx, job_id, &operation_id, now).await?;
materialize_items(tx, job_id, &operation_id, now).await?;
finalize_if_empty(tx, job_id, &operation_id, now).await?;
enqueue(
&mut tx,
tx,
"job.updated",
json!({ "job_id": job_id, "deletion_operation_id": operation_id }),
)
.await?;
let operation = load_operation_in_tx(&mut tx, operation_id.as_str()).await?;
tx.commit().await?;
let operation = load_operation_in_tx(tx, operation_id.as_str()).await?;
Ok(HardDeleteOutcome {
mutation: LifecycleMutation::Changed,
operation,