Separate orchestration by protocol action

This commit is contained in:
ms2k 2026-07-19 14:27:59 +09:00
commit 6f0f20f5cc
4 changed files with 6974 additions and 6839 deletions

View file

@ -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(

View 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,
}))
}
}

View 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)
}
}
}
}

View 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))
}
}