//! Detailed status reasons.
use std::fmt;
use aws_config::SdkConfig;
use aws_sdk_sts::{error::DecodeAuthorizationMessageError, types::SdkError};
use lazy_static::lazy_static;
use regex::Regex;
/// A wrapper around a status reason that offers additional detail.
///
/// This is the return type of [`StackEventDetails::resource_status_reason`][1]. The [`detail`][2]
/// method will attempt to parse the inner status reason into [`StatusReasonDetail`], which can
/// indicate what specifically went wrong. The underlying status reason can be retrieved via
/// [`inner`][3].
///
/// [1]: crate::StackEventDetails::resource_status_reason
/// [2]: Self::detail
/// [3]: Self::inner
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StatusReason<'a>(Option<&'a str>);
impl<'a> StatusReason<'a> {
pub(crate) fn new(status_reason: Option<&'a str>) -> Self {
Self(status_reason)
}
/// The raw status reason, in case you need to work with it directly.
#[must_use]
pub fn inner(&self) -> Option<&'a str> {
self.0
}
/// Additional detail about the status reason, if available.
///
/// This currently depends on some preset parsing of the status reason string for various common
/// error reasons. See [`StatusReasonDetail`] for current possibilities.
pub fn detail(&self) -> Option<StatusReasonDetail<'a>> {
self.0.and_then(StatusReasonDetail::new)
}
}
/// Additional detail about a status reason.
#[allow(clippy::module_name_repetitions)]
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum StatusReasonDetail<'a> {
/// Resource creation was cancelled, typically due to a preceding failure.
CreationCancelled,
/// The CloudFormation principal did not have permission to perform an operation.
///
/// This is similar to [`AuthorizationFailure`](Self::AuthorizationFailure) but provides some
/// information without needing to decode the failure message (if any).
MissingPermission(MissingPermission<'a>),
/// The CloudFormation principal was not authorized to perform an operation.
///
/// This is similar to [`MissingPermission`](Self::MissingPermission) but provides no
/// information without decoding the failure message (if any). AWS does this with the reasoning
/// that details of why authorization failed might be sensitive. You can decode the message with
/// [`EncodedAuthorizationMessage::decode`].
AuthorizationFailure(EncodedAuthorizationMessage<'a>),
/// A stack operation failed due to resource errors.
ResourceErrors(ResourceErrors<'a>),
}
impl<'a> StatusReasonDetail<'a> {
fn new(status_reason: &'a str) -> Option<Self> {
lazy_static! {
static ref CREATION_CANCELLED: Regex =
Regex::new(r"(?i)Resource creation cancelled").unwrap();
static ref MISSING_PERMISSION_1: Regex =
Regex::new(r"(?i)API: (?P<permission>[a-z0-9]+:[a-z0-9]+)\b").unwrap();
static ref MISSING_PERMISSION_2: Regex =
Regex::new(r"(?i)User: (?P<principal>[a-z0-9:/-]+) is not authorized to perform: (?P<permission>[a-z0-9]+:[a-z0-9]+)").unwrap();
static ref RESOURCE_ERRORS: Regex =
Regex::new(r"(?i)The following resource\(s\) failed to (?:create|delete|update): \[(?P<logical_resource_ids>[a-z0-9]+(?:, *[a-z0-9]+)*)\]").unwrap();
static ref ENCODED_AUTHORIZATION_MESSAGE: Regex =
Regex::new("(?i)Encoded authorization failure message: (?P<encoded_authorization_message>[a-z0-9_-]+)").unwrap();
}
if CREATION_CANCELLED.is_match(status_reason) {
return Some(Self::CreationCancelled);
}
let encoded_authorization_message = ENCODED_AUTHORIZATION_MESSAGE
.captures(status_reason)
.map(|captures| {
EncodedAuthorizationMessage::new(
captures
.name("encoded_authorization_message")
.unwrap()
.as_str(),
)
});
if let Some(detail) = MISSING_PERMISSION_1.captures(status_reason) {
return Some(Self::MissingPermission(MissingPermission {
permission: detail.name("permission").unwrap().as_str(),
principal: None,
encoded_authorization_message,
}));
}
if let Some(detail) = MISSING_PERMISSION_2.captures(status_reason) {
return Some(Self::MissingPermission(MissingPermission {
permission: detail.name("permission").unwrap().as_str(),
principal: Some(detail.name("principal").unwrap().as_str()),
encoded_authorization_message,
}));
}
if let Some(encoded_authorization_message) = encoded_authorization_message {
return Some(Self::AuthorizationFailure(encoded_authorization_message));
}
if let Some(detail) = RESOURCE_ERRORS.captures(status_reason) {
return Some(Self::ResourceErrors(ResourceErrors {
logical_resource_ids: detail.name("logical_resource_ids").unwrap().as_str(),
}));
}
None
}
}
/// The CloudFormation principal did not have permission to perform an operation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissingPermission<'a> {
/// The IAM permission that was missing.
pub permission: &'a str,
/// The CloudFormation principal.
///
/// This is not reported by all missing permission status reasons, and so may not be known. If
/// you controlled the stack operation invocation you could still determine this either from the
/// `RoleArn` input parameter, or else the principal that started the operation.
pub principal: Option<&'a str>,
/// An encoded authorization failure message included in the status reason.
pub encoded_authorization_message: Option<EncodedAuthorizationMessage<'a>>,
}
/// An encoded authorization failure message.
///
/// The message is encoded because the details of the authorization status can constitute privileged
/// information that the user who requested the operation should not see. To decode an authorization
/// status message, a user must be granted permissions via an IAM policy to request the
/// `DecodeAuthorizationMessage` (`sts:DecodeAuthorizationMessage`) action.
///
/// You can decode the message using [`decode`](Self::decode).
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EncodedAuthorizationMessage<'a>(&'a str);
impl<'a> EncodedAuthorizationMessage<'a> {
pub(crate) fn new(message: &'a str) -> Self {
Self(message)
}
/// The raw encoded authorization message, in case you need to work with it directly.
#[must_use]
pub fn inner(&self) -> &'a str {
self.0
}
/// Decode the authorization message.
///
/// This involves invoking the `sts:DecodeAuthorizationMessage` API, so an STS client is
/// required and will need permission to invoke the API.
///
/// The decoded message includes the following type of information:
///
/// - Whether the request was denied due to an explicit deny or due to the absence of an
/// explicit allow.
/// - The principal who made the request.
/// - The requested action.
/// - The requested resource.
/// - The values of condition keys in the context of the user's request.
///
/// Note that the structure of the message is not fully specified, so for now it is returned as
/// JSON. This may change in future.
///
/// # Errors
///
/// Any errors encountered when invoking the `sts:DecodeAuthorizationMessage` API are returned.
pub async fn decode(
&self,
config: &SdkConfig,
) -> Result<serde_json::Value, EncodedAuthorizationMessageDecodeError> {
let sts = aws_sdk_sts::Client::new(config);
let output = sts
.decode_authorization_message()
.encoded_message(self.0.to_owned())
.send()
.await
.map_err(EncodedAuthorizationMessageDecodeError::from_sdk)?;
let message = output
.decoded_message
.expect("decode authorization message response without decoded_message");
Ok(serde_json::from_str(&message).expect("decoded authorization message isn't JSON"))
}
}
/// The error returned by [`EncodedAuthorizationMessage::decode`].
#[derive(Debug)]
pub struct EncodedAuthorizationMessageDecodeError(Box<dyn std::error::Error>);
impl EncodedAuthorizationMessageDecodeError {
fn from_sdk(error: SdkError<DecodeAuthorizationMessageError>) -> Self {
Self(error.into())
}
}
impl fmt::Display for EncodedAuthorizationMessageDecodeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for EncodedAuthorizationMessageDecodeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.0.source()
}
}
/// A stack operation failed due to resource errors.
#[derive(Clone, Debug, Eq)]
pub struct ResourceErrors<'a> {
logical_resource_ids: &'a str,
}
impl<'a> ResourceErrors<'a> {
/// The logical resource IDs of resources that failed.
pub fn logical_resource_ids(&self) -> impl Iterator<Item = &'a str> {
lazy_static! {
static ref LOGICAL_RESOURCE_ID: Regex = Regex::new("(?i)[a-z0-9]+").unwrap();
}
LOGICAL_RESOURCE_ID
.find_iter(self.logical_resource_ids)
.map(|m| m.as_str())
}
}
/// Equality is implemented explicitly over [`logical_resource_ids`](Self::logical_resource_ids),
/// rather than derived structurally.
impl PartialEq for ResourceErrors<'_> {
fn eq(&self, other: &Self) -> bool {
self.logical_resource_ids().eq(other.logical_resource_ids())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_reason_detail() {
#![allow(clippy::shadow_unrelated)]
let example = r#"Resource creation cancelled"#;
assert_eq!(
StatusReasonDetail::new(example),
Some(StatusReasonDetail::CreationCancelled)
);
let example = r#"API: ec2:ModifyVpcAttribute You are not authorized to perform this operation. Encoded authorization failure message: g1-YvnBabE1x9q868e9rU4VX9gFjPpt31dEvX6uYDMWmdkou9pGLq85c3Wy4IAr3CwKrF8Jqu0aIkiy0TBM5SU22pSjE-gzZuP1dg5rvyhI1fl5DBB4DiDyRmZpOjovE2w0MMxuM4QFqf6zAtlbCtwdCYVxHwTpKrlkQAJEr40twnTPWe1_Vh-YRfprV9RBis8nReUcf87GV1oGFxjLujid4oOAinD-NmpIUR5VLCw2ycoOZihPR_unBC9stRioVeYiBg-Q1T5IU-J-xEQK092YuR-H4vqMm5Nwg4l1kN10t8pbFb_YopmILVfvh-ViLBbzE0cO6ZlvLvcMcB8crsbgLP10H05hPtHDIGUMwc_xM-y_9SUAcrVUfPKdM4JeMvNMLkFfuLcgMIjTivxG1y3DwligaBXrSwKVkkMB4XfswrU7nYT6PO0cIyD_v7vw5kPJP1EafEZGVMJrJJEwS43FVFkLCMIi6eSxyFTYRF4GUbkuXbTpfMxYdivdFdiofA6_JsC-AZXwcE3qXAHpJ3PrH6lYfWm8z0m8PATAQKTqlcEMIYNngNnmnqasBQ_anBj-C7BT4V_B67wOOhc_Vwheq6xKnsI7XfsTgzsmHdFZDVIBCrdw"#;
assert_eq!(
StatusReasonDetail::new(example),
Some(StatusReasonDetail::MissingPermission(MissingPermission {
permission: "ec2:ModifyVpcAttribute",
principal: None,
encoded_authorization_message: Some(EncodedAuthorizationMessage::new("g1-YvnBabE1x9q868e9rU4VX9gFjPpt31dEvX6uYDMWmdkou9pGLq85c3Wy4IAr3CwKrF8Jqu0aIkiy0TBM5SU22pSjE-gzZuP1dg5rvyhI1fl5DBB4DiDyRmZpOjovE2w0MMxuM4QFqf6zAtlbCtwdCYVxHwTpKrlkQAJEr40twnTPWe1_Vh-YRfprV9RBis8nReUcf87GV1oGFxjLujid4oOAinD-NmpIUR5VLCw2ycoOZihPR_unBC9stRioVeYiBg-Q1T5IU-J-xEQK092YuR-H4vqMm5Nwg4l1kN10t8pbFb_YopmILVfvh-ViLBbzE0cO6ZlvLvcMcB8crsbgLP10H05hPtHDIGUMwc_xM-y_9SUAcrVUfPKdM4JeMvNMLkFfuLcgMIjTivxG1y3DwligaBXrSwKVkkMB4XfswrU7nYT6PO0cIyD_v7vw5kPJP1EafEZGVMJrJJEwS43FVFkLCMIi6eSxyFTYRF4GUbkuXbTpfMxYdivdFdiofA6_JsC-AZXwcE3qXAHpJ3PrH6lYfWm8z0m8PATAQKTqlcEMIYNngNnmnqasBQ_anBj-C7BT4V_B67wOOhc_Vwheq6xKnsI7XfsTgzsmHdFZDVIBCrdw")),
}))
);
let example = r#"API: s3:CreateBucket Access Denied"#;
assert_eq!(
StatusReasonDetail::new(example),
Some(StatusReasonDetail::MissingPermission(MissingPermission {
permission: "s3:CreateBucket",
principal: None,
encoded_authorization_message: None,
}))
);
let example = r#"Resource handler returned message: "User: arn:aws:iam::012345678910:user/cloudformatious-cli-testing is not authorized to perform: elasticfilesystem:CreateFileSystem on the specified resource (Service: Efs, Status Code: 403, Request ID: fedb2f85-ff52-496c-b7be-207a23072587, Extended Request ID: null)" (RequestToken: ccd41719-eae9-3614-3b35-1d1cc3ad55da, HandlerErrorCode: GeneralServiceException)"#;
assert_eq!(
StatusReasonDetail::new(example),
Some(StatusReasonDetail::MissingPermission(MissingPermission {
permission: "elasticfilesystem:CreateFileSystem",
principal: Some("arn:aws:iam::012345678910:user/cloudformatious-cli-testing"),
encoded_authorization_message: None,
}))
);
let example =
r#"The following resource(s) failed to create: [Vpc, Fs]. Rollback requested by user."#;
let detail = StatusReasonDetail::new(example).unwrap();
assert_eq!(
detail,
StatusReasonDetail::ResourceErrors(ResourceErrors {
logical_resource_ids: "Vpc, Fs",
})
);
if let StatusReasonDetail::ResourceErrors(resource_errors) = detail {
assert_eq!(
resource_errors.logical_resource_ids().collect::<Vec<_>>(),
vec!["Vpc", "Fs"]
);
} else {
unreachable!()
}
}
}