canic-core 0.93.0

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
//! Module: workflow::runtime::auth::prepare::replay
//!
//! Responsibility: own prepare-request replay identity, decisions, and response encoding.
//! Does not own: request admission, proof creation, or replay storage mutation.
//! Boundary: deterministic adapter between auth prepare workflows and replay ops.

use crate::{
    InternalError, InternalErrorOrigin,
    dto::{
        auth::{
            AuthRequestMetadata, DelegatedRoleGrant, DelegatedTokenPrepareRequest,
            DelegatedTokenPrepareResponse, DelegationAudience, RoleAttestationPrepareResponse,
            RoleAttestationRequest,
        },
        error::Error,
        rpc::RootRequestMetadata,
    },
    model::replay::{CommandKind, OperationId, ReplayActor, ReplayPayloadHasher, ReplayReceipt},
    ops::replay::{
        self as replay_ops,
        receipt::{ReplayReceiptDecision, ReplayReceiptReserveInput, ReplayReceiptStoreError},
    },
};

const ROLE_ATTESTATION_REPLAY_COMMAND_KIND: &str = "auth.prepare_role_attestation.v1";
const MAX_ROLE_ATTESTATION_REPLAY_TTL_NS: u64 = 300_000_000_000;
const TOKEN_PREPARE_REPLAY_COMMAND_KIND: &str = "auth.prepare_delegated_token.v1";
pub(super) const MAX_TOKEN_REPLAY_TTL_NS: u64 = 300_000_000_000;

pub(super) fn replay_reserve_input(
    command_kind: CommandKind,
    request_id: [u8; 32],
    actor: ReplayActor,
    payload_hash: [u8; 32],
    now_ns: u64,
    ttl_ns: u64,
    overflow_message: &'static str,
) -> Result<ReplayReceiptReserveInput, InternalError> {
    let expires_at_ns = now_ns
        .checked_add(ttl_ns)
        .ok_or_else(|| InternalError::public(Error::invalid(overflow_message)))?;
    Ok(ReplayReceiptReserveInput::new(
        command_kind,
        OperationId::from_bytes(request_id),
        actor,
        payload_hash,
        now_ns,
    )
    .with_expires_at_ns(expires_at_ns))
}

pub(super) fn role_attestation_replay_metadata(
    metadata: Option<RootRequestMetadata>,
) -> Result<RootRequestMetadata, InternalError> {
    let metadata = metadata.ok_or_else(|| InternalError::public(Error::operation_id_required()))?;
    if metadata.ttl_ns == 0 {
        return Err(InternalError::public(Error::invalid(
            "role attestation replay metadata ttl_ns must be greater than zero",
        )));
    }
    if metadata.ttl_ns > MAX_ROLE_ATTESTATION_REPLAY_TTL_NS {
        return Err(InternalError::public(Error::invalid(format!(
            "role attestation replay metadata ttl_ns={} exceeds max {}",
            metadata.ttl_ns, MAX_ROLE_ATTESTATION_REPLAY_TTL_NS
        ))));
    }
    Ok(metadata)
}

pub(super) fn token_replay_metadata(
    metadata: Option<AuthRequestMetadata>,
    label: &str,
) -> Result<AuthRequestMetadata, InternalError> {
    let metadata = metadata.ok_or_else(|| InternalError::public(Error::operation_id_required()))?;
    if metadata.ttl_ns == 0 {
        return Err(InternalError::public(Error::invalid(format!(
            "{label} replay metadata ttl_ns must be greater than zero"
        ))));
    }
    if metadata.ttl_ns > MAX_TOKEN_REPLAY_TTL_NS {
        return Err(InternalError::public(Error::invalid(format!(
            "{label} replay metadata ttl_ns={} exceeds max {}",
            metadata.ttl_ns, MAX_TOKEN_REPLAY_TTL_NS
        ))));
    }
    Ok(metadata)
}

pub(super) fn role_attestation_replay_command_kind() -> CommandKind {
    CommandKind::new(ROLE_ATTESTATION_REPLAY_COMMAND_KIND)
        .expect("role attestation replay command kind is a valid static label")
}

pub(super) fn token_prepare_replay_command_kind() -> CommandKind {
    CommandKind::new(TOKEN_PREPARE_REPLAY_COMMAND_KIND)
        .expect("delegated-token prepare replay command kind is a valid static label")
}

pub(super) fn role_attestation_replay_payload_hash(
    command_kind: &CommandKind,
    actor: &ReplayActor,
    request: &RoleAttestationRequest,
) -> [u8; 32] {
    let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
    hasher.hash_principal(&request.subject);
    hasher.hash_role(&request.role);
    hasher.hash_bool(request.subnet_id.is_some());
    if let Some(subnet_id) = request.subnet_id {
        hasher.hash_principal(&subnet_id);
    }
    hasher.hash_principal(&request.audience);
    hasher.hash_u64(request.ttl_ns);
    hasher.hash_u64(request.epoch);
    hasher.finish()
}

pub(super) fn token_prepare_replay_payload_hash(
    command_kind: &CommandKind,
    actor: &ReplayActor,
    request: &DelegatedTokenPrepareRequest,
) -> [u8; 32] {
    let mut hasher = ReplayPayloadHasher::new(command_kind, actor);
    hasher.hash_principal(&request.subject);
    hash_delegation_audience(&mut hasher, &request.aud);
    hash_delegated_role_grants(&mut hasher, &request.grants);
    hasher.hash_u64(request.ttl_ns);
    hash_optional_bytes(&mut hasher, request.ext.as_deref());
    hasher.finish()
}

fn hash_delegation_audience(hasher: &mut ReplayPayloadHasher, aud: &DelegationAudience) {
    match aud {
        DelegationAudience::Canister(canister) => {
            hasher.hash_str("canister");
            hasher.hash_principal(canister);
        }
        DelegationAudience::CanicSubnet(subnet) => {
            hasher.hash_str("canic_subnet");
            hasher.hash_principal(subnet);
        }
        DelegationAudience::Project(project) => {
            hasher.hash_str("project");
            hasher.hash_str(project);
        }
    }
}

fn hash_optional_bytes(hasher: &mut ReplayPayloadHasher, bytes: Option<&[u8]>) {
    hasher.hash_bool(bytes.is_some());
    if let Some(bytes) = bytes {
        hasher.hash_bytes(bytes);
    }
}

fn hash_delegated_role_grants(hasher: &mut ReplayPayloadHasher, grants: &[DelegatedRoleGrant]) {
    hasher.hash_u64(grants.len() as u64);
    for grant in grants {
        hasher.hash_role(&grant.target);
        hash_string_vec(hasher, &grant.scopes);
    }
}

fn hash_string_vec(hasher: &mut ReplayPayloadHasher, values: &[String]) {
    hasher.hash_u64(values.len() as u64);
    for value in values {
        hasher.hash_str(value);
    }
}

pub(super) fn map_token_prepare_replay_decision(
    decision: ReplayReceiptDecision,
) -> Result<DelegatedTokenPrepareResponse, InternalError> {
    match decision {
        ReplayReceiptDecision::Fresh(_) => Err(InternalError::invariant(
            InternalErrorOrigin::Workflow,
            "fresh delegated token replay decision escaped",
        )),
        ReplayReceiptDecision::ReturnCommitted(receipt) => decode_token_prepare_response(&receipt),
        ReplayReceiptDecision::OperationInProgress => Err(InternalError::public(Error::conflict(
            "delegated token prepare request is already in progress; retry later with the same request id",
        ))),
        ReplayReceiptDecision::ActorMismatch => Err(InternalError::public(Error::conflict(
            "delegated token prepare request id was reused by a different caller",
        ))),
        ReplayReceiptDecision::PayloadMismatch => Err(InternalError::public(Error::conflict(
            "delegated token prepare request id was reused with a different payload",
        ))),
        ReplayReceiptDecision::Expired => Err(InternalError::public(Error::conflict(
            "delegated token prepare replay receipt expired; retry with a new request id",
        ))),
        ReplayReceiptDecision::RecoveryRequired(reason) => {
            Err(InternalError::public(Error::conflict(format!(
                "delegated token prepare request requires recovery before replay: {reason:?}"
            ))))
        }
        ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
            Err(InternalError::public(Error::exhausted(format!(
                "delegated token prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
            ))))
        }
        ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
            Err(InternalError::public(Error::exhausted(format!(
                "delegated token prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
            ))))
        }
    }
}

pub(super) fn map_token_prepare_replay_store_error(err: ReplayReceiptStoreError) -> InternalError {
    match err {
        ReplayReceiptStoreError::ReceiptDecodeFailed(message) => InternalError::workflow(
            InternalErrorOrigin::Workflow,
            format!("failed to decode delegated token prepare replay receipt: {message}"),
        ),
    }
}

pub(super) fn encode_token_prepare_response(
    response: &DelegatedTokenPrepareResponse,
) -> Result<Vec<u8>, InternalError> {
    replay_ops::encode_delegated_token_prepare_replay_response(response).map_err(|err| match err {
        replay_ops::ReplayCommitError::EncodeFailed(message) => InternalError::workflow(
            InternalErrorOrigin::Workflow,
            format!("failed to encode delegated token prepare replay response: {message}"),
        ),
    })
}

fn decode_token_prepare_response(
    receipt: &ReplayReceipt,
) -> Result<DelegatedTokenPrepareResponse, InternalError> {
    replay_ops::decode_delegated_token_prepare_replay_response(receipt).map_err(|err| match err {
        replay_ops::ReplayDecodeError::DecodeFailed(message) => {
            InternalError::workflow(InternalErrorOrigin::Workflow, message)
        }
    })
}

pub(super) fn map_role_attestation_replay_decision(
    decision: ReplayReceiptDecision,
) -> Result<RoleAttestationPrepareResponse, InternalError> {
    match decision {
        ReplayReceiptDecision::Fresh(_) => Err(InternalError::invariant(
            InternalErrorOrigin::Workflow,
            "fresh role attestation replay decision escaped",
        )),
        ReplayReceiptDecision::ReturnCommitted(receipt) => {
            decode_role_attestation_prepare_response(&receipt)
        }
        ReplayReceiptDecision::OperationInProgress => Err(InternalError::public(Error::conflict(
            "role attestation prepare request is already in progress; retry later with the same request id",
        ))),
        ReplayReceiptDecision::ActorMismatch => Err(InternalError::public(Error::conflict(
            "role attestation prepare request id was reused by a different caller",
        ))),
        ReplayReceiptDecision::PayloadMismatch => Err(InternalError::public(Error::conflict(
            "role attestation prepare request id was reused with a different payload",
        ))),
        ReplayReceiptDecision::Expired => Err(InternalError::public(Error::conflict(
            "role attestation prepare replay receipt expired; retry with a new request id",
        ))),
        ReplayReceiptDecision::RecoveryRequired(reason) => {
            Err(InternalError::public(Error::conflict(format!(
                "role attestation prepare request requires recovery before replay: {reason:?}"
            ))))
        }
        ReplayReceiptDecision::PendingActorQuotaExceeded { max_pending, .. } => {
            Err(InternalError::public(Error::exhausted(format!(
                "role attestation prepare pending replay receipt quota exceeded for caller; max_pending={max_pending}"
            ))))
        }
        ReplayReceiptDecision::PendingCommandQuotaExceeded { max_pending, .. } => {
            Err(InternalError::public(Error::exhausted(format!(
                "role attestation prepare pending replay receipt quota exceeded for command kind; max_pending={max_pending}"
            ))))
        }
    }
}

pub(super) fn map_role_attestation_replay_store_error(
    err: ReplayReceiptStoreError,
) -> InternalError {
    match err {
        ReplayReceiptStoreError::ReceiptDecodeFailed(message) => InternalError::workflow(
            InternalErrorOrigin::Workflow,
            format!("failed to decode role attestation replay receipt: {message}"),
        ),
    }
}

pub(super) fn encode_role_attestation_prepare_response(
    response: &RoleAttestationPrepareResponse,
) -> Result<Vec<u8>, InternalError> {
    replay_ops::encode_role_attestation_prepare_replay_response(response).map_err(|err| match err {
        replay_ops::ReplayCommitError::EncodeFailed(message) => InternalError::workflow(
            InternalErrorOrigin::Workflow,
            format!("failed to encode role attestation prepare replay response: {message}"),
        ),
    })
}

fn decode_role_attestation_prepare_response(
    receipt: &ReplayReceipt,
) -> Result<RoleAttestationPrepareResponse, InternalError> {
    replay_ops::decode_role_attestation_prepare_replay_response(receipt).map_err(|err| match err {
        replay_ops::ReplayDecodeError::DecodeFailed(message) => {
            InternalError::workflow(InternalErrorOrigin::Workflow, message)
        }
    })
}