1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
//! Detailed status reasons.

use std::fmt;

use aws_config::SdkConfig;
use aws_sdk_sts::{
    error::SdkError, operation::decode_authorization_message::DecodeAuthorizationMessageError,
};
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.
    ///
    /// # Panics
    ///
    /// This will panic if the `sts:DecodeAuthorizationMessage` API does not repond with a decoded
    /// message (this is allowed by AWS SDK types but should never happen per the semantics of the
    /// API).
    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!()
        }
    }
}