Separate orchestration by protocol action
This commit is contained in:
parent
a61ba4d365
commit
6f0f20f5cc
4 changed files with 6974 additions and 6839 deletions
|
|
@ -16,7 +16,10 @@ use crate::types::{
|
|||
};
|
||||
use crate::vault::{EmptyPathFence, VaultClient};
|
||||
|
||||
mod grants;
|
||||
mod query;
|
||||
mod reconciliation;
|
||||
mod writes;
|
||||
|
||||
/// Durable source-credential registry with Vault-backed secret versions.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -66,884 +69,6 @@ impl CredentialRegistry {
|
|||
time::Duration::try_from(minimum_age).unwrap_or(time::Duration::MAX);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
owner: &OpaqueId,
|
||||
request: CreateCredential,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let prepared = self.prepare_create(owner, request).await?;
|
||||
let credential_id = prepared.credential_id.clone();
|
||||
let intent_id = prepared.intent_id.clone();
|
||||
let now = prepared.created_at;
|
||||
let result = with_transaction(&self.pool, move |tx| {
|
||||
Box::pin(async move { Self::commit_prepared_create_in_tx(tx, prepared).await })
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(credential) => Ok(credential),
|
||||
Err(error) => {
|
||||
if let Ok(Some(credential)) = self.get_internal(&credential_id).await {
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::VaultWritten,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
return Ok(credential);
|
||||
}
|
||||
Err(error.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_create(
|
||||
&self,
|
||||
owner: &OpaqueId,
|
||||
request: CreateCredential,
|
||||
) -> Result<PreparedCredentialCreate, CredentialError> {
|
||||
validate_label(&request.label)?;
|
||||
request.material.validate()?;
|
||||
let kind = request.material.kind();
|
||||
let intent_id = new_intent_id()?;
|
||||
let credential_id = new_credential_id()?;
|
||||
let version_id = new_version_id()?;
|
||||
let secret_mount = self.vault.config().mount.clone();
|
||||
let credential_path = self.vault.config().credential_path_for(&credential_id);
|
||||
let secret_path = self
|
||||
.vault
|
||||
.config()
|
||||
.secret_path_for(&credential_id, &version_id);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
insert_intent(
|
||||
&self.pool,
|
||||
IntentInsert {
|
||||
intent_id: &intent_id,
|
||||
operation: IntentOperation::Create,
|
||||
credential_id: &credential_id,
|
||||
version_id: &version_id,
|
||||
owner_id: Some(owner),
|
||||
label: Some(&request.label),
|
||||
credential_kind: Some(kind),
|
||||
secret_mount: &secret_mount,
|
||||
secret_path: &secret_path,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let vault_kv_version = match self
|
||||
.vault
|
||||
.put_version(&secret_mount, &secret_path, &request.material)
|
||||
.await
|
||||
{
|
||||
Ok(version) => version,
|
||||
Err(error) => {
|
||||
// Vault may have applied the write with a lost response; leave prepared
|
||||
// for age-gated reconcile to discover via metadata.
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
record_vault_written_with_retry(&self.pool, &intent_id, vault_kv_version, now).await?;
|
||||
Ok(PreparedCredentialCreate {
|
||||
intent_id,
|
||||
credential_id,
|
||||
version_id,
|
||||
owner: owner.clone(),
|
||||
label: request.label,
|
||||
kind,
|
||||
vault_kv_version,
|
||||
secret_mount,
|
||||
credential_path,
|
||||
secret_path,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn commit_prepared_create_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
prepared: PreparedCredentialCreate,
|
||||
) -> Result<SourceCredential, DurableError> {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&prepared.credential_id)).await?;
|
||||
if !claim_intent_durable_committed_in_tx(tx, &prepared.intent_id, prepared.created_at)
|
||||
.await?
|
||||
{
|
||||
return Err(DurableError::Effect(
|
||||
"create intent was claimed by reconciliation".into(),
|
||||
));
|
||||
}
|
||||
insert_credential_with_version(
|
||||
tx,
|
||||
InsertCredential {
|
||||
credential_id: &prepared.credential_id,
|
||||
owner: &prepared.owner,
|
||||
label: &prepared.label,
|
||||
kind: prepared.kind,
|
||||
version_id: &prepared.version_id,
|
||||
vault_kv_version: prepared.vault_kv_version,
|
||||
secret_mount: &prepared.secret_mount,
|
||||
credential_path: &prepared.credential_path,
|
||||
secret_path: &prepared.secret_path,
|
||||
now: prepared.created_at,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
get_internal_in_tx(tx, &prepared.credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("created credential is missing".into()))
|
||||
}
|
||||
|
||||
pub async fn rotate(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
material: SecretMaterial,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let prepared = self.prepare_rotate(caller, credential_id, material).await?;
|
||||
let intent_id = prepared.intent_id.clone();
|
||||
let version_id = prepared.version_id.clone();
|
||||
let secret_mount = prepared.secret_mount.clone();
|
||||
let secret_path = prepared.secret_path.clone();
|
||||
let vault_kv_version = prepared.vault_kv_version;
|
||||
let now = prepared.created_at;
|
||||
let result = with_transaction(&self.pool, move |tx| {
|
||||
Box::pin(async move { Self::commit_prepared_rotate_in_tx(tx, prepared).await })
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(RotateTxOutcome::Applied(credential)) => Ok(credential),
|
||||
Ok(RotateTxOutcome::Revoked) => {
|
||||
if self
|
||||
.vault
|
||||
.destroy_version(&secret_mount, &secret_path, vault_kv_version)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::Abandoning,
|
||||
IntentStatus::Abandoned,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(CredentialError::Revoked)
|
||||
}
|
||||
Ok(RotateTxOutcome::IntentClaimed) => Err(CredentialError::Config(
|
||||
"rotate intent was claimed by reconciliation".into(),
|
||||
)),
|
||||
Err(error) => {
|
||||
if version_row_exists(&self.pool, &version_id).await? {
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::VaultWritten,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
if let Ok(Some(credential)) = self.get_internal(credential_id).await {
|
||||
return Ok(credential);
|
||||
}
|
||||
}
|
||||
Err(error.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_rotate(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
material: SecretMaterial,
|
||||
) -> Result<PreparedCredentialRotate, CredentialError> {
|
||||
material.validate()?;
|
||||
let existing = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
if existing.status == SourceCredentialStatus::Revoked {
|
||||
return Err(CredentialError::Revoked);
|
||||
}
|
||||
if material.kind() != existing.kind {
|
||||
return Err(CredentialError::KindMismatch);
|
||||
}
|
||||
|
||||
let intent_id = new_intent_id()?;
|
||||
let version_id = new_version_id()?;
|
||||
let SecretLocation {
|
||||
mount: secret_mount,
|
||||
path: credential_path,
|
||||
} = self
|
||||
.load_secret_location(credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let secret_path = version_secret_path(&credential_path, &version_id);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
insert_intent(
|
||||
&self.pool,
|
||||
IntentInsert {
|
||||
intent_id: &intent_id,
|
||||
operation: IntentOperation::Rotate,
|
||||
credential_id,
|
||||
version_id: &version_id,
|
||||
owner_id: None,
|
||||
label: None,
|
||||
credential_kind: None,
|
||||
secret_mount: &secret_mount,
|
||||
secret_path: &secret_path,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let vault_kv_version = self
|
||||
.vault
|
||||
.put_version(&secret_mount, &secret_path, &material)
|
||||
.await?;
|
||||
record_vault_written_with_retry(&self.pool, &intent_id, vault_kv_version, now).await?;
|
||||
Ok(PreparedCredentialRotate {
|
||||
intent_id,
|
||||
credential_id: credential_id.clone(),
|
||||
version_id,
|
||||
vault_kv_version,
|
||||
secret_mount,
|
||||
secret_path,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn commit_prepared_rotate_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
prepared: PreparedCredentialRotate,
|
||||
) -> Result<RotateTxOutcome, DurableError> {
|
||||
lock_credential(tx, &prepared.credential_id).await?;
|
||||
let status: String =
|
||||
sqlx::query_scalar("SELECT status FROM source_credentials WHERE credential_id = $1")
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("credential missing under lock".into()))?;
|
||||
if status != "active" {
|
||||
claim_intent_abandoning_in_tx(
|
||||
tx,
|
||||
&prepared.intent_id,
|
||||
prepared.vault_kv_version,
|
||||
prepared.created_at,
|
||||
)
|
||||
.await?;
|
||||
return Ok(RotateTxOutcome::Revoked);
|
||||
}
|
||||
if !claim_intent_durable_committed_in_tx(tx, &prepared.intent_id, prepared.created_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(RotateTxOutcome::IntentClaimed);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO source_credential_versions (
|
||||
version_id, credential_id, vault_kv_version,
|
||||
secret_mount, secret_path, created_at, destroyed_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NULL)",
|
||||
)
|
||||
.bind(prepared.version_id.as_str())
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.bind(prepared.vault_kv_version as i64)
|
||||
.bind(&prepared.secret_mount)
|
||||
.bind(&prepared.secret_path)
|
||||
.bind(prepared.created_at)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE source_credentials
|
||||
SET active_version_id = $2, updated_at = $3
|
||||
WHERE credential_id = $1",
|
||||
)
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.bind(prepared.version_id.as_str())
|
||||
.bind(prepared.created_at)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
let credential = get_internal_in_tx(tx, &prepared.credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("rotated credential is missing".into()))?;
|
||||
Ok(RotateTxOutcome::Applied(credential))
|
||||
}
|
||||
|
||||
pub async fn revoke(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let caller = caller.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
with_transaction(&self.pool, {
|
||||
move |tx| Box::pin(async move { Self::revoke_in_tx(tx, &caller, &credential_id).await })
|
||||
})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn revoke_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<SourceCredential, DurableError> {
|
||||
lock_credential(tx, credential_id).await?;
|
||||
let existing = get_internal_in_tx(tx, credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect(CredentialError::NotFound.to_string()))?;
|
||||
require_manage(caller, Some(&existing)).map_err(DurableError::effect)?;
|
||||
if existing.status == SourceCredentialStatus::Revoked {
|
||||
return Ok(existing);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
sqlx::query(
|
||||
"UPDATE source_credentials
|
||||
SET status = 'revoked',
|
||||
active_version_id = NULL,
|
||||
updated_at = $2
|
||||
WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.bind(now)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
get_internal_in_tx(tx, credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("revoked credential is missing".into()))
|
||||
}
|
||||
|
||||
/// Pin the current authorized version for a new execution grant.
|
||||
///
|
||||
/// Serializes with revoke/rotate on the credential advisory lock so a
|
||||
/// concurrent revocation cannot leave a pin after status becomes revoked.
|
||||
pub async fn pin_current_version(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<CredentialGrantVersion, CredentialError> {
|
||||
let _ = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
with_transaction(&self.pool, {
|
||||
let credential_id = credential_id.clone();
|
||||
move |tx| Box::pin(async move { pin_current_version_in_tx(tx, &credential_id).await })
|
||||
})
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
DurableError::Effect(message) if message == EFFECT_REVOKED => CredentialError::Revoked,
|
||||
DurableError::Effect(message) if message == EFFECT_NO_ACTIVE => {
|
||||
CredentialError::NoActiveVersion
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_MISSING => CredentialError::NotFound,
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Pin under an existing transaction for atomic composition with admission.
|
||||
///
|
||||
/// Takes the same credential advisory lock as revoke. Caller must already
|
||||
/// authorize the owner/administrator relationship.
|
||||
pub async fn pin_current_version_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<CredentialGrantVersion, CredentialError> {
|
||||
pin_current_version_in_tx(tx, credential_id)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
DurableError::Effect(message) if message == EFFECT_REVOKED => {
|
||||
CredentialError::Revoked
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_NO_ACTIVE => {
|
||||
CredentialError::NoActiveVersion
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_MISSING => {
|
||||
CredentialError::NotFound
|
||||
}
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn authorize_and_pin_current_version_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<(CredentialGrantVersion, SourceCredentialKind), CredentialError> {
|
||||
lock_credential(tx, credential_id).await?;
|
||||
let row = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT owner_id, kind FROM source_credentials WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let owner = OpaqueId::parse(row.0)?;
|
||||
if !crate::authz::may_manage(caller, &owner) {
|
||||
return Err(CredentialError::NotFound);
|
||||
}
|
||||
let kind = SourceCredentialKind::parse(&row.1)?;
|
||||
let pin = Self::pin_current_version_in_tx(tx, credential_id).await?;
|
||||
Ok((pin, kind))
|
||||
}
|
||||
|
||||
pub async fn bind_execution_grant_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
grant_id: &OpaqueId,
|
||||
pin: &CredentialGrantVersion,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), CredentialError> {
|
||||
let resolvable: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1 FROM source_credential_versions
|
||||
WHERE credential_id = $1 AND version_id = $2 AND destroyed_at IS NULL
|
||||
)",
|
||||
)
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.fetch_one(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
if !resolvable {
|
||||
return Err(CredentialError::Unresolvable);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_grant_credential_pins (
|
||||
grant_id, credential_id, version_id, status, created_at, revoked_at
|
||||
) VALUES ($1, $2, $3, 'active', $4, NULL)",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bind a pin to an execution grant for worker resolution.
|
||||
///
|
||||
/// Resolution always re-reads this durable binding, so revocation fails
|
||||
/// closed even if workers retain the grant id.
|
||||
pub async fn bind_execution_grant(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
pin: &CredentialGrantVersion,
|
||||
) -> Result<remora_domain::ports::ActiveCredentialGrant, CredentialError> {
|
||||
let coord = self
|
||||
.load_version_coord(&pin.credential_id, &pin.version_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::Unresolvable)?;
|
||||
if coord.destroyed {
|
||||
return Err(CredentialError::Unresolvable);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO execution_grant_credential_pins (
|
||||
grant_id, credential_id, version_id, status, created_at, revoked_at
|
||||
) VALUES ($1, $2, $3, 'active', $4, NULL)
|
||||
ON CONFLICT (grant_id) DO NOTHING",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
if result.rows_affected() == 0 {
|
||||
let existing = self
|
||||
.load_grant_pin_row(grant_id)
|
||||
.await?
|
||||
.ok_or_else(|| CredentialError::Config("grant pin conflict without row".into()))?;
|
||||
if existing.status != "active"
|
||||
|| existing.credential_id != pin.credential_id.as_str()
|
||||
|| existing.version_id != pin.version_id.as_str()
|
||||
{
|
||||
return Err(CredentialError::InvalidInput(
|
||||
"execution grant already bound to a different pin or is revoked".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(remora_domain::ports::ActiveCredentialGrant::for_execution_grant(grant_id.clone()))
|
||||
}
|
||||
|
||||
/// Load the durable pin for an active execution grant (fresh activity check).
|
||||
pub async fn load_active_grant_pin(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
) -> Result<Option<CredentialGrantVersion>, CredentialError> {
|
||||
let row = self.load_grant_pin_row(grant_id).await?;
|
||||
match row {
|
||||
Some(row) if row.status == "active" => Ok(Some(CredentialGrantVersion {
|
||||
credential_id: OpaqueId::parse(row.credential_id)?,
|
||||
version_id: OpaqueId::parse(row.version_id)?,
|
||||
})),
|
||||
Some(_) | None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Destroy a still-referenced version so grant-bound resolution fails closed.
|
||||
pub async fn destroy_version(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
) -> Result<(), CredentialError> {
|
||||
let _ = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let row = sqlx::query_as::<_, VersionCoordRow>(
|
||||
"SELECT v.version_id, v.credential_id, v.vault_kv_version, v.destroyed_at,
|
||||
v.secret_mount, v.secret_path
|
||||
FROM source_credential_versions v
|
||||
WHERE v.version_id = $1 AND v.credential_id = $2",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
|
||||
if row.destroyed_at.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.vault
|
||||
.destroy_version(
|
||||
&row.secret_mount,
|
||||
&row.secret_path,
|
||||
row.vault_kv_version as u64,
|
||||
)
|
||||
.await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
sqlx::query(
|
||||
"UPDATE source_credential_versions
|
||||
SET destroyed_at = $2
|
||||
WHERE version_id = $1 AND destroyed_at IS NULL",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete or abandon stuck secret-write intents.
|
||||
///
|
||||
/// Only intents older than the configured minimum age are selected. Before
|
||||
/// any Vault destroy, the reconciler claims the intent as `abandoning`
|
||||
/// under the credential advisory lock so create/rotate cannot materialize
|
||||
/// that version afterward. Failed destroys leave `abandoning` for retry.
|
||||
///
|
||||
/// Selects `prepared` (lost Vault response), `vault_written`, and
|
||||
/// `abandoning` (retry cleanup) intents.
|
||||
pub async fn reconcile_secret_write_intents(&self) -> Result<usize, CredentialError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let min_age = self.reconcile_min_age;
|
||||
let cutoff = now - min_age;
|
||||
let rows = sqlx::query_as::<_, IntentRow>(
|
||||
"SELECT intent_id, credential_id, version_id, secret_mount, secret_path
|
||||
FROM secret_write_intents
|
||||
WHERE (status IN ('prepared', 'vault_written') AND updated_at < $1)
|
||||
OR status = 'abandoning'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
|
||||
let mut handled = 0usize;
|
||||
for row in rows {
|
||||
if self.reconcile_one_intent(row, now).await? {
|
||||
handled += 1;
|
||||
}
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
async fn reconcile_one_intent(
|
||||
&self,
|
||||
row: IntentRow,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
let intent_id = OpaqueId::parse(row.intent_id.clone())?;
|
||||
let credential_id = OpaqueId::parse(row.credential_id.clone())?;
|
||||
let version_id = OpaqueId::parse(row.version_id.clone())?;
|
||||
let reconcile_min_age = self.reconcile_min_age;
|
||||
|
||||
let decision = with_transaction(&self.pool, {
|
||||
let intent_id = intent_id.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
let version_id = version_id.clone();
|
||||
move |tx| {
|
||||
Box::pin(async move {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&credential_id)).await?;
|
||||
let current = sqlx::query_as::<_, IntentStatusRow>(
|
||||
"SELECT status, vault_kv_version, updated_at
|
||||
FROM secret_write_intents WHERE intent_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(intent_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("intent missing".into()))?;
|
||||
|
||||
let open = matches!(
|
||||
current.status.as_str(),
|
||||
"prepared" | "vault_written" | "abandoning"
|
||||
);
|
||||
if !open {
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
// Age gate applies to first claim only; abandoning retries
|
||||
// immediately so failed Vault destroy is not deferred.
|
||||
if current.status != "abandoning"
|
||||
&& current.updated_at + reconcile_min_age > now
|
||||
{
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
|
||||
if version_row_exists_in_tx(tx, &version_id).await? {
|
||||
mark_intent_status_in_tx(
|
||||
tx,
|
||||
&intent_id,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ReconcileDecision::Committed);
|
||||
}
|
||||
|
||||
match (
|
||||
current.status.as_str(),
|
||||
current.vault_kv_version.map(|v| v as u64),
|
||||
) {
|
||||
("prepared", None) => Ok(ReconcileDecision::InspectPrepared),
|
||||
("vault_written" | "abandoning" | "prepared", Some(kv)) => {
|
||||
claim_intent_abandoning_in_tx(tx, &intent_id, kv, now).await?;
|
||||
Ok(ReconcileDecision::ClaimedDestroy(kv))
|
||||
}
|
||||
_ => Ok(ReconcileDecision::Skip),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match decision {
|
||||
Ok(ReconcileDecision::Skip) => Ok(false),
|
||||
Ok(ReconcileDecision::Committed) => Ok(true),
|
||||
Ok(ReconcileDecision::InspectPrepared) => {
|
||||
self.reconcile_prepared_intent(&intent_id, &credential_id, &version_id, &row, now)
|
||||
.await
|
||||
}
|
||||
Ok(ReconcileDecision::ClaimedDestroy(vault_kv_version)) => {
|
||||
self.reconcile_claimed_destroy(&intent_id, &row, vault_kv_version, now)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_prepared_intent(
|
||||
&self,
|
||||
intent_id: &OpaqueId,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
row: &IntentRow,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
let live = self
|
||||
.vault
|
||||
.list_live_versions(&row.secret_mount, &row.secret_path)
|
||||
.await?;
|
||||
let mut live_versions: Vec<u64> = live.into_iter().map(|entry| entry.version).collect();
|
||||
if live_versions.is_empty() {
|
||||
match self
|
||||
.vault
|
||||
.fence_empty_path(&row.secret_mount, &row.secret_path)
|
||||
.await?
|
||||
{
|
||||
EmptyPathFence::Created(version) => live_versions.push(version),
|
||||
EmptyPathFence::Occupied => {
|
||||
live_versions = self
|
||||
.vault
|
||||
.list_live_versions(&row.secret_mount, &row.secret_path)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|entry| entry.version)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let decision = with_transaction(&self.pool, {
|
||||
let intent_id = intent_id.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
let version_id = version_id.clone();
|
||||
let live_versions = live_versions.clone();
|
||||
move |tx| {
|
||||
Box::pin(async move {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&credential_id)).await?;
|
||||
let current = sqlx::query_as::<_, IntentStatusRow>(
|
||||
"SELECT status, vault_kv_version, updated_at
|
||||
FROM secret_write_intents WHERE intent_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(intent_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("intent missing".into()))?;
|
||||
if current.status != "prepared" {
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
if version_row_exists_in_tx(tx, &version_id).await? {
|
||||
mark_intent_status_in_tx(
|
||||
tx,
|
||||
&intent_id,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ReconcileDecision::Committed);
|
||||
}
|
||||
match live_versions.as_slice() {
|
||||
[] => Ok(ReconcileDecision::Skip),
|
||||
[kv] => {
|
||||
claim_intent_abandoning_in_tx(tx, &intent_id, *kv, now).await?;
|
||||
Ok(ReconcileDecision::ClaimedDestroy(*kv))
|
||||
}
|
||||
_ => Ok(ReconcileDecision::Skip),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
match decision {
|
||||
ReconcileDecision::Skip => Ok(false),
|
||||
ReconcileDecision::Committed => Ok(true),
|
||||
ReconcileDecision::ClaimedDestroy(vault_kv_version) => {
|
||||
self.reconcile_claimed_destroy(intent_id, row, vault_kv_version, now)
|
||||
.await
|
||||
}
|
||||
ReconcileDecision::InspectPrepared => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconcile_claimed_destroy(
|
||||
&self,
|
||||
intent_id: &OpaqueId,
|
||||
row: &IntentRow,
|
||||
vault_kv_version: u64,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
match self
|
||||
.vault
|
||||
.destroy_version(&row.secret_mount, &row.secret_path, vault_kv_version)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
mark_intent_status_if(
|
||||
&self.pool,
|
||||
intent_id,
|
||||
IntentStatus::Abandoning,
|
||||
IntentStatus::Abandoned,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) => {
|
||||
// Leave abandoning so the next reconcile retries destroy.
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_grant_pin_row(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
) -> Result<Option<GrantPinRow>, CredentialError> {
|
||||
sqlx::query_as::<_, GrantPinRow>(
|
||||
"SELECT grant_id, credential_id, version_id, status
|
||||
FROM execution_grant_credential_pins
|
||||
WHERE grant_id = $1",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_version_coord(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
) -> Result<Option<VersionCoord>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, VersionCoordRow>(
|
||||
"SELECT v.version_id, v.credential_id, v.vault_kv_version, v.destroyed_at,
|
||||
v.secret_mount, v.secret_path
|
||||
FROM source_credential_versions v
|
||||
WHERE v.version_id = $1 AND v.credential_id = $2",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
row.map(VersionCoordRow::into_coord).transpose()
|
||||
}
|
||||
|
||||
async fn get_internal(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<Option<SourceCredential>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, CredentialRow>(
|
||||
"SELECT c.credential_id, c.owner_id, c.label, c.kind, c.status,
|
||||
c.created_at, c.updated_at,
|
||||
v.version_id AS active_version_id, v.created_at AS active_version_created_at
|
||||
FROM source_credentials c
|
||||
LEFT JOIN source_credential_versions v
|
||||
ON v.version_id = c.active_version_id
|
||||
WHERE c.credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
row.map(CredentialRow::into_record).transpose()
|
||||
}
|
||||
|
||||
async fn load_secret_location(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<Option<SecretLocation>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, SecretLocationRow>(
|
||||
"SELECT secret_mount, secret_path FROM source_credentials WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(row.map(|row| SecretLocation {
|
||||
mount: row.secret_mount,
|
||||
path: row.secret_path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_internal_in_tx(
|
||||
|
|
|
|||
278
crates/remora-credentials/src/registry/grants.rs
Normal file
278
crates/remora-credentials/src/registry/grants.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use super::*;
|
||||
|
||||
impl CredentialRegistry {
|
||||
pub async fn revoke(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let caller = caller.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
with_transaction(&self.pool, {
|
||||
move |tx| Box::pin(async move { Self::revoke_in_tx(tx, &caller, &credential_id).await })
|
||||
})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn revoke_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<SourceCredential, DurableError> {
|
||||
lock_credential(tx, credential_id).await?;
|
||||
let existing = get_internal_in_tx(tx, credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect(CredentialError::NotFound.to_string()))?;
|
||||
require_manage(caller, Some(&existing)).map_err(DurableError::effect)?;
|
||||
if existing.status == SourceCredentialStatus::Revoked {
|
||||
return Ok(existing);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
sqlx::query(
|
||||
"UPDATE source_credentials
|
||||
SET status = 'revoked',
|
||||
active_version_id = NULL,
|
||||
updated_at = $2
|
||||
WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.bind(now)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
get_internal_in_tx(tx, credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("revoked credential is missing".into()))
|
||||
}
|
||||
|
||||
pub async fn pin_current_version(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<CredentialGrantVersion, CredentialError> {
|
||||
let _ = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
with_transaction(&self.pool, {
|
||||
let credential_id = credential_id.clone();
|
||||
move |tx| Box::pin(async move { pin_current_version_in_tx(tx, &credential_id).await })
|
||||
})
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
DurableError::Effect(message) if message == EFFECT_REVOKED => CredentialError::Revoked,
|
||||
DurableError::Effect(message) if message == EFFECT_NO_ACTIVE => {
|
||||
CredentialError::NoActiveVersion
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_MISSING => CredentialError::NotFound,
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn pin_current_version_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<CredentialGrantVersion, CredentialError> {
|
||||
pin_current_version_in_tx(tx, credential_id)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
DurableError::Effect(message) if message == EFFECT_REVOKED => {
|
||||
CredentialError::Revoked
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_NO_ACTIVE => {
|
||||
CredentialError::NoActiveVersion
|
||||
}
|
||||
DurableError::Effect(message) if message == EFFECT_MISSING => {
|
||||
CredentialError::NotFound
|
||||
}
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn authorize_and_pin_current_version_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<(CredentialGrantVersion, SourceCredentialKind), CredentialError> {
|
||||
lock_credential(tx, credential_id).await?;
|
||||
let row = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT owner_id, kind FROM source_credentials WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let owner = OpaqueId::parse(row.0)?;
|
||||
if !crate::authz::may_manage(caller, &owner) {
|
||||
return Err(CredentialError::NotFound);
|
||||
}
|
||||
let kind = SourceCredentialKind::parse(&row.1)?;
|
||||
let pin = Self::pin_current_version_in_tx(tx, credential_id).await?;
|
||||
Ok((pin, kind))
|
||||
}
|
||||
|
||||
pub async fn bind_execution_grant_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
grant_id: &OpaqueId,
|
||||
pin: &CredentialGrantVersion,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), CredentialError> {
|
||||
let resolvable: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1 FROM source_credential_versions
|
||||
WHERE credential_id = $1 AND version_id = $2 AND destroyed_at IS NULL
|
||||
)",
|
||||
)
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.fetch_one(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
if !resolvable {
|
||||
return Err(CredentialError::Unresolvable);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_grant_credential_pins (
|
||||
grant_id, credential_id, version_id, status, created_at, revoked_at
|
||||
) VALUES ($1, $2, $3, 'active', $4, NULL)",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(tx.as_mut())
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn bind_execution_grant(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
pin: &CredentialGrantVersion,
|
||||
) -> Result<remora_domain::ports::ActiveCredentialGrant, CredentialError> {
|
||||
let coord = self
|
||||
.load_version_coord(&pin.credential_id, &pin.version_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::Unresolvable)?;
|
||||
if coord.destroyed {
|
||||
return Err(CredentialError::Unresolvable);
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO execution_grant_credential_pins (
|
||||
grant_id, credential_id, version_id, status, created_at, revoked_at
|
||||
) VALUES ($1, $2, $3, 'active', $4, NULL)
|
||||
ON CONFLICT (grant_id) DO NOTHING",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.bind(pin.credential_id.as_str())
|
||||
.bind(pin.version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
if result.rows_affected() == 0 {
|
||||
let existing = self
|
||||
.load_grant_pin_row(grant_id)
|
||||
.await?
|
||||
.ok_or_else(|| CredentialError::Config("grant pin conflict without row".into()))?;
|
||||
if existing.status != "active"
|
||||
|| existing.credential_id != pin.credential_id.as_str()
|
||||
|| existing.version_id != pin.version_id.as_str()
|
||||
{
|
||||
return Err(CredentialError::InvalidInput(
|
||||
"execution grant already bound to a different pin or is revoked".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(remora_domain::ports::ActiveCredentialGrant::for_execution_grant(grant_id.clone()))
|
||||
}
|
||||
|
||||
pub async fn load_active_grant_pin(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
) -> Result<Option<CredentialGrantVersion>, CredentialError> {
|
||||
let row = self.load_grant_pin_row(grant_id).await?;
|
||||
match row {
|
||||
Some(row) if row.status == "active" => Ok(Some(CredentialGrantVersion {
|
||||
credential_id: OpaqueId::parse(row.credential_id)?,
|
||||
version_id: OpaqueId::parse(row.version_id)?,
|
||||
})),
|
||||
Some(_) | None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn load_grant_pin_row(
|
||||
&self,
|
||||
grant_id: &OpaqueId,
|
||||
) -> Result<Option<GrantPinRow>, CredentialError> {
|
||||
sqlx::query_as::<_, GrantPinRow>(
|
||||
"SELECT grant_id, credential_id, version_id, status
|
||||
FROM execution_grant_credential_pins
|
||||
WHERE grant_id = $1",
|
||||
)
|
||||
.bind(grant_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_version_coord(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
) -> Result<Option<VersionCoord>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, VersionCoordRow>(
|
||||
"SELECT v.version_id, v.credential_id, v.vault_kv_version, v.destroyed_at,
|
||||
v.secret_mount, v.secret_path
|
||||
FROM source_credential_versions v
|
||||
WHERE v.version_id = $1 AND v.credential_id = $2",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
row.map(VersionCoordRow::into_coord).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn get_internal(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<Option<SourceCredential>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, CredentialRow>(
|
||||
"SELECT c.credential_id, c.owner_id, c.label, c.kind, c.status,
|
||||
c.created_at, c.updated_at,
|
||||
v.version_id AS active_version_id, v.created_at AS active_version_created_at
|
||||
FROM source_credentials c
|
||||
LEFT JOIN source_credential_versions v
|
||||
ON v.version_id = c.active_version_id
|
||||
WHERE c.credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
row.map(CredentialRow::into_record).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn load_secret_location(
|
||||
&self,
|
||||
credential_id: &OpaqueId,
|
||||
) -> Result<Option<SecretLocation>, CredentialError> {
|
||||
let row = sqlx::query_as::<_, SecretLocationRow>(
|
||||
"SELECT secret_mount, secret_path FROM source_credentials WHERE credential_id = $1",
|
||||
)
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(row.map(|row| SecretLocation {
|
||||
mount: row.secret_mount,
|
||||
path: row.secret_path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
275
crates/remora-credentials/src/registry/reconciliation.rs
Normal file
275
crates/remora-credentials/src/registry/reconciliation.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
use super::*;
|
||||
|
||||
impl CredentialRegistry {
|
||||
pub async fn destroy_version(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
) -> Result<(), CredentialError> {
|
||||
let _ = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let row = sqlx::query_as::<_, VersionCoordRow>(
|
||||
"SELECT v.version_id, v.credential_id, v.vault_kv_version, v.destroyed_at,
|
||||
v.secret_mount, v.secret_path
|
||||
FROM source_credential_versions v
|
||||
WHERE v.version_id = $1 AND v.credential_id = $2",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(credential_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
|
||||
if row.destroyed_at.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.vault
|
||||
.destroy_version(
|
||||
&row.secret_mount,
|
||||
&row.secret_path,
|
||||
row.vault_kv_version as u64,
|
||||
)
|
||||
.await?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
sqlx::query(
|
||||
"UPDATE source_credential_versions
|
||||
SET destroyed_at = $2
|
||||
WHERE version_id = $1 AND destroyed_at IS NULL",
|
||||
)
|
||||
.bind(version_id.as_str())
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reconcile_secret_write_intents(&self) -> Result<usize, CredentialError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let min_age = self.reconcile_min_age;
|
||||
let cutoff = now - min_age;
|
||||
let rows = sqlx::query_as::<_, IntentRow>(
|
||||
"SELECT intent_id, credential_id, version_id, secret_mount, secret_path
|
||||
FROM secret_write_intents
|
||||
WHERE (status IN ('prepared', 'vault_written') AND updated_at < $1)
|
||||
OR status = 'abandoning'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DurableError::from)?;
|
||||
|
||||
let mut handled = 0usize;
|
||||
for row in rows {
|
||||
if self.reconcile_one_intent(row, now).await? {
|
||||
handled += 1;
|
||||
}
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_one_intent(
|
||||
&self,
|
||||
row: IntentRow,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
let intent_id = OpaqueId::parse(row.intent_id.clone())?;
|
||||
let credential_id = OpaqueId::parse(row.credential_id.clone())?;
|
||||
let version_id = OpaqueId::parse(row.version_id.clone())?;
|
||||
let reconcile_min_age = self.reconcile_min_age;
|
||||
|
||||
let decision = with_transaction(&self.pool, {
|
||||
let intent_id = intent_id.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
let version_id = version_id.clone();
|
||||
move |tx| {
|
||||
Box::pin(async move {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&credential_id)).await?;
|
||||
let current = sqlx::query_as::<_, IntentStatusRow>(
|
||||
"SELECT status, vault_kv_version, updated_at
|
||||
FROM secret_write_intents WHERE intent_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(intent_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("intent missing".into()))?;
|
||||
|
||||
let open = matches!(
|
||||
current.status.as_str(),
|
||||
"prepared" | "vault_written" | "abandoning"
|
||||
);
|
||||
if !open {
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
// Age gate applies to first claim only; abandoning retries
|
||||
// immediately so failed Vault destroy is not deferred.
|
||||
if current.status != "abandoning"
|
||||
&& current.updated_at + reconcile_min_age > now
|
||||
{
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
|
||||
if version_row_exists_in_tx(tx, &version_id).await? {
|
||||
mark_intent_status_in_tx(
|
||||
tx,
|
||||
&intent_id,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ReconcileDecision::Committed);
|
||||
}
|
||||
|
||||
match (
|
||||
current.status.as_str(),
|
||||
current.vault_kv_version.map(|v| v as u64),
|
||||
) {
|
||||
("prepared", None) => Ok(ReconcileDecision::InspectPrepared),
|
||||
("vault_written" | "abandoning" | "prepared", Some(kv)) => {
|
||||
claim_intent_abandoning_in_tx(tx, &intent_id, kv, now).await?;
|
||||
Ok(ReconcileDecision::ClaimedDestroy(kv))
|
||||
}
|
||||
_ => Ok(ReconcileDecision::Skip),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match decision {
|
||||
Ok(ReconcileDecision::Skip) => Ok(false),
|
||||
Ok(ReconcileDecision::Committed) => Ok(true),
|
||||
Ok(ReconcileDecision::InspectPrepared) => {
|
||||
self.reconcile_prepared_intent(&intent_id, &credential_id, &version_id, &row, now)
|
||||
.await
|
||||
}
|
||||
Ok(ReconcileDecision::ClaimedDestroy(vault_kv_version)) => {
|
||||
self.reconcile_claimed_destroy(&intent_id, &row, vault_kv_version, now)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_prepared_intent(
|
||||
&self,
|
||||
intent_id: &OpaqueId,
|
||||
credential_id: &OpaqueId,
|
||||
version_id: &OpaqueId,
|
||||
row: &IntentRow,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
let live = self
|
||||
.vault
|
||||
.list_live_versions(&row.secret_mount, &row.secret_path)
|
||||
.await?;
|
||||
let mut live_versions: Vec<u64> = live.into_iter().map(|entry| entry.version).collect();
|
||||
if live_versions.is_empty() {
|
||||
match self
|
||||
.vault
|
||||
.fence_empty_path(&row.secret_mount, &row.secret_path)
|
||||
.await?
|
||||
{
|
||||
EmptyPathFence::Created(version) => live_versions.push(version),
|
||||
EmptyPathFence::Occupied => {
|
||||
live_versions = self
|
||||
.vault
|
||||
.list_live_versions(&row.secret_mount, &row.secret_path)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|entry| entry.version)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let decision = with_transaction(&self.pool, {
|
||||
let intent_id = intent_id.clone();
|
||||
let credential_id = credential_id.clone();
|
||||
let version_id = version_id.clone();
|
||||
let live_versions = live_versions.clone();
|
||||
move |tx| {
|
||||
Box::pin(async move {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&credential_id)).await?;
|
||||
let current = sqlx::query_as::<_, IntentStatusRow>(
|
||||
"SELECT status, vault_kv_version, updated_at
|
||||
FROM secret_write_intents WHERE intent_id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(intent_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("intent missing".into()))?;
|
||||
if current.status != "prepared" {
|
||||
return Ok(ReconcileDecision::Skip);
|
||||
}
|
||||
if version_row_exists_in_tx(tx, &version_id).await? {
|
||||
mark_intent_status_in_tx(
|
||||
tx,
|
||||
&intent_id,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
return Ok(ReconcileDecision::Committed);
|
||||
}
|
||||
match live_versions.as_slice() {
|
||||
[] => Ok(ReconcileDecision::Skip),
|
||||
[kv] => {
|
||||
claim_intent_abandoning_in_tx(tx, &intent_id, *kv, now).await?;
|
||||
Ok(ReconcileDecision::ClaimedDestroy(*kv))
|
||||
}
|
||||
_ => Ok(ReconcileDecision::Skip),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
match decision {
|
||||
ReconcileDecision::Skip => Ok(false),
|
||||
ReconcileDecision::Committed => Ok(true),
|
||||
ReconcileDecision::ClaimedDestroy(vault_kv_version) => {
|
||||
self.reconcile_claimed_destroy(intent_id, row, vault_kv_version, now)
|
||||
.await
|
||||
}
|
||||
ReconcileDecision::InspectPrepared => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_claimed_destroy(
|
||||
&self,
|
||||
intent_id: &OpaqueId,
|
||||
row: &IntentRow,
|
||||
vault_kv_version: u64,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<bool, CredentialError> {
|
||||
match self
|
||||
.vault
|
||||
.destroy_version(&row.secret_mount, &row.secret_path, vault_kv_version)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
mark_intent_status_if(
|
||||
&self.pool,
|
||||
intent_id,
|
||||
IntentStatus::Abandoning,
|
||||
IntentStatus::Abandoned,
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) => {
|
||||
// Leave abandoning so the next reconcile retries destroy.
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
311
crates/remora-credentials/src/registry/writes.rs
Normal file
311
crates/remora-credentials/src/registry/writes.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
use super::*;
|
||||
|
||||
impl CredentialRegistry {
|
||||
pub async fn create(
|
||||
&self,
|
||||
owner: &OpaqueId,
|
||||
request: CreateCredential,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let prepared = self.prepare_create(owner, request).await?;
|
||||
let credential_id = prepared.credential_id.clone();
|
||||
let intent_id = prepared.intent_id.clone();
|
||||
let now = prepared.created_at;
|
||||
let result = with_transaction(&self.pool, move |tx| {
|
||||
Box::pin(async move { Self::commit_prepared_create_in_tx(tx, prepared).await })
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(credential) => Ok(credential),
|
||||
Err(error) => {
|
||||
if let Ok(Some(credential)) = self.get_internal(&credential_id).await {
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::VaultWritten,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
return Ok(credential);
|
||||
}
|
||||
Err(error.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_create(
|
||||
&self,
|
||||
owner: &OpaqueId,
|
||||
request: CreateCredential,
|
||||
) -> Result<PreparedCredentialCreate, CredentialError> {
|
||||
validate_label(&request.label)?;
|
||||
request.material.validate()?;
|
||||
let kind = request.material.kind();
|
||||
let intent_id = new_intent_id()?;
|
||||
let credential_id = new_credential_id()?;
|
||||
let version_id = new_version_id()?;
|
||||
let secret_mount = self.vault.config().mount.clone();
|
||||
let credential_path = self.vault.config().credential_path_for(&credential_id);
|
||||
let secret_path = self
|
||||
.vault
|
||||
.config()
|
||||
.secret_path_for(&credential_id, &version_id);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
insert_intent(
|
||||
&self.pool,
|
||||
IntentInsert {
|
||||
intent_id: &intent_id,
|
||||
operation: IntentOperation::Create,
|
||||
credential_id: &credential_id,
|
||||
version_id: &version_id,
|
||||
owner_id: Some(owner),
|
||||
label: Some(&request.label),
|
||||
credential_kind: Some(kind),
|
||||
secret_mount: &secret_mount,
|
||||
secret_path: &secret_path,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let vault_kv_version = match self
|
||||
.vault
|
||||
.put_version(&secret_mount, &secret_path, &request.material)
|
||||
.await
|
||||
{
|
||||
Ok(version) => version,
|
||||
Err(error) => {
|
||||
// Vault may have applied the write with a lost response; leave prepared
|
||||
// for age-gated reconcile to discover via metadata.
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
record_vault_written_with_retry(&self.pool, &intent_id, vault_kv_version, now).await?;
|
||||
Ok(PreparedCredentialCreate {
|
||||
intent_id,
|
||||
credential_id,
|
||||
version_id,
|
||||
owner: owner.clone(),
|
||||
label: request.label,
|
||||
kind,
|
||||
vault_kv_version,
|
||||
secret_mount,
|
||||
credential_path,
|
||||
secret_path,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn commit_prepared_create_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
prepared: PreparedCredentialCreate,
|
||||
) -> Result<SourceCredential, DurableError> {
|
||||
advisory_xact_lock(tx, credential_advisory_key(&prepared.credential_id)).await?;
|
||||
if !claim_intent_durable_committed_in_tx(tx, &prepared.intent_id, prepared.created_at)
|
||||
.await?
|
||||
{
|
||||
return Err(DurableError::Effect(
|
||||
"create intent was claimed by reconciliation".into(),
|
||||
));
|
||||
}
|
||||
insert_credential_with_version(
|
||||
tx,
|
||||
InsertCredential {
|
||||
credential_id: &prepared.credential_id,
|
||||
owner: &prepared.owner,
|
||||
label: &prepared.label,
|
||||
kind: prepared.kind,
|
||||
version_id: &prepared.version_id,
|
||||
vault_kv_version: prepared.vault_kv_version,
|
||||
secret_mount: &prepared.secret_mount,
|
||||
credential_path: &prepared.credential_path,
|
||||
secret_path: &prepared.secret_path,
|
||||
now: prepared.created_at,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
get_internal_in_tx(tx, &prepared.credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("created credential is missing".into()))
|
||||
}
|
||||
|
||||
pub async fn rotate(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
material: SecretMaterial,
|
||||
) -> Result<SourceCredential, CredentialError> {
|
||||
let prepared = self.prepare_rotate(caller, credential_id, material).await?;
|
||||
let intent_id = prepared.intent_id.clone();
|
||||
let version_id = prepared.version_id.clone();
|
||||
let secret_mount = prepared.secret_mount.clone();
|
||||
let secret_path = prepared.secret_path.clone();
|
||||
let vault_kv_version = prepared.vault_kv_version;
|
||||
let now = prepared.created_at;
|
||||
let result = with_transaction(&self.pool, move |tx| {
|
||||
Box::pin(async move { Self::commit_prepared_rotate_in_tx(tx, prepared).await })
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(RotateTxOutcome::Applied(credential)) => Ok(credential),
|
||||
Ok(RotateTxOutcome::Revoked) => {
|
||||
if self
|
||||
.vault
|
||||
.destroy_version(&secret_mount, &secret_path, vault_kv_version)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::Abandoning,
|
||||
IntentStatus::Abandoned,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(CredentialError::Revoked)
|
||||
}
|
||||
Ok(RotateTxOutcome::IntentClaimed) => Err(CredentialError::Config(
|
||||
"rotate intent was claimed by reconciliation".into(),
|
||||
)),
|
||||
Err(error) => {
|
||||
if version_row_exists(&self.pool, &version_id).await? {
|
||||
let _ = mark_intent_status_if(
|
||||
&self.pool,
|
||||
&intent_id,
|
||||
IntentStatus::VaultWritten,
|
||||
IntentStatus::DurableCommitted,
|
||||
now,
|
||||
)
|
||||
.await;
|
||||
if let Ok(Some(credential)) = self.get_internal(credential_id).await {
|
||||
return Ok(credential);
|
||||
}
|
||||
}
|
||||
Err(error.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_rotate(
|
||||
&self,
|
||||
caller: &CredentialCaller,
|
||||
credential_id: &OpaqueId,
|
||||
material: SecretMaterial,
|
||||
) -> Result<PreparedCredentialRotate, CredentialError> {
|
||||
material.validate()?;
|
||||
let existing = self
|
||||
.get(caller, credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
if existing.status == SourceCredentialStatus::Revoked {
|
||||
return Err(CredentialError::Revoked);
|
||||
}
|
||||
if material.kind() != existing.kind {
|
||||
return Err(CredentialError::KindMismatch);
|
||||
}
|
||||
|
||||
let intent_id = new_intent_id()?;
|
||||
let version_id = new_version_id()?;
|
||||
let SecretLocation {
|
||||
mount: secret_mount,
|
||||
path: credential_path,
|
||||
} = self
|
||||
.load_secret_location(credential_id)
|
||||
.await?
|
||||
.ok_or(CredentialError::NotFound)?;
|
||||
let secret_path = version_secret_path(&credential_path, &version_id);
|
||||
let now = OffsetDateTime::now_utc();
|
||||
|
||||
insert_intent(
|
||||
&self.pool,
|
||||
IntentInsert {
|
||||
intent_id: &intent_id,
|
||||
operation: IntentOperation::Rotate,
|
||||
credential_id,
|
||||
version_id: &version_id,
|
||||
owner_id: None,
|
||||
label: None,
|
||||
credential_kind: None,
|
||||
secret_mount: &secret_mount,
|
||||
secret_path: &secret_path,
|
||||
now,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let vault_kv_version = self
|
||||
.vault
|
||||
.put_version(&secret_mount, &secret_path, &material)
|
||||
.await?;
|
||||
record_vault_written_with_retry(&self.pool, &intent_id, vault_kv_version, now).await?;
|
||||
Ok(PreparedCredentialRotate {
|
||||
intent_id,
|
||||
credential_id: credential_id.clone(),
|
||||
version_id,
|
||||
vault_kv_version,
|
||||
secret_mount,
|
||||
secret_path,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn commit_prepared_rotate_in_tx(
|
||||
tx: &mut PgTx<'_>,
|
||||
prepared: PreparedCredentialRotate,
|
||||
) -> Result<RotateTxOutcome, DurableError> {
|
||||
lock_credential(tx, &prepared.credential_id).await?;
|
||||
let status: String =
|
||||
sqlx::query_scalar("SELECT status FROM source_credentials WHERE credential_id = $1")
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.fetch_optional(tx.as_mut())
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("credential missing under lock".into()))?;
|
||||
if status != "active" {
|
||||
claim_intent_abandoning_in_tx(
|
||||
tx,
|
||||
&prepared.intent_id,
|
||||
prepared.vault_kv_version,
|
||||
prepared.created_at,
|
||||
)
|
||||
.await?;
|
||||
return Ok(RotateTxOutcome::Revoked);
|
||||
}
|
||||
if !claim_intent_durable_committed_in_tx(tx, &prepared.intent_id, prepared.created_at)
|
||||
.await?
|
||||
{
|
||||
return Ok(RotateTxOutcome::IntentClaimed);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO source_credential_versions (
|
||||
version_id, credential_id, vault_kv_version,
|
||||
secret_mount, secret_path, created_at, destroyed_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NULL)",
|
||||
)
|
||||
.bind(prepared.version_id.as_str())
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.bind(prepared.vault_kv_version as i64)
|
||||
.bind(&prepared.secret_mount)
|
||||
.bind(&prepared.secret_path)
|
||||
.bind(prepared.created_at)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE source_credentials
|
||||
SET active_version_id = $2, updated_at = $3
|
||||
WHERE credential_id = $1",
|
||||
)
|
||||
.bind(prepared.credential_id.as_str())
|
||||
.bind(prepared.version_id.as_str())
|
||||
.bind(prepared.created_at)
|
||||
.execute(tx.as_mut())
|
||||
.await?;
|
||||
let credential = get_internal_in_tx(tx, &prepared.credential_id)
|
||||
.await?
|
||||
.ok_or_else(|| DurableError::Effect("rotated credential is missing".into()))?;
|
||||
Ok(RotateTxOutcome::Applied(credential))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue