Skip to main content

fakecloud_core/
auth_message.rs

1//! Encode and decode the opaque deny-reason token returned to clients
2//! when an IAM check denies a request, and consumed by
3//! `sts:DecodeAuthorizationMessage`.
4//!
5//! AWS treats the encoded message as an opaque blob; the documented
6//! contract is "pass it back to DecodeAuthorizationMessage and you'll
7//! get JSON describing why the request was denied". We do the same:
8//! the token is a deflate-compressed JSON document, base64-encoded.
9//! The decoder reverses the transformation, so any deny-time site that
10//! calls [`encode_deny`] gets a real round-trip without needing a
11//! separate state map.
12//!
13//! This module lives in `fakecloud-core` so the dispatch layer (which
14//! turns `Decision::Deny` into an `AccessDeniedException`) can produce
15//! the encoded message inline. `fakecloud-iam` re-exports the same
16//! functions for the STS service that decodes them.
17
18use base64::Engine;
19use flate2::read::ZlibDecoder;
20use flate2::write::ZlibEncoder;
21use flate2::Compression;
22use serde_json::{json, Value};
23use std::io::{Read, Write};
24
25/// Build an encoded authorization message describing a deny decision.
26/// The shape mirrors what AWS returns from
27/// `DecodeAuthorizationMessage`: an `allowed` flag, an `explicitDeny`
28/// flag, and a `matchedStatements.items` array. Optional supplementary
29/// keys (`action`, `principal`, `context`) are included so an operator
30/// inspecting the decoded blob can see why the request failed.
31pub fn encode_deny(
32    explicit: bool,
33    action: Option<&str>,
34    principal_arn: Option<&str>,
35    matched_statements: Vec<Value>,
36    context: Option<Value>,
37) -> String {
38    let mut payload = json!({
39        "allowed": false,
40        "explicitDeny": explicit,
41        "matchedStatements": { "items": matched_statements },
42    });
43    if let Some(a) = action {
44        payload["action"] = json!(a);
45    }
46    if let Some(p) = principal_arn {
47        payload["principal"] = json!(p);
48    }
49    if let Some(c) = context {
50        payload["context"] = c;
51    }
52    let json_bytes = serde_json::to_vec(&payload).unwrap_or_default();
53    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
54    encoder.write_all(&json_bytes).ok();
55    let compressed = encoder.finish().unwrap_or_default();
56    base64::engine::general_purpose::STANDARD.encode(compressed)
57}
58
59/// Reverse [`encode_deny`]. Returns the JSON document the encoder
60/// stashed, or an `InvalidAuthorizationMessageException`-shaped error
61/// when the token isn't recognizable. Tokens that decode but don't
62/// look like deny payloads are still returned verbatim — AWS's
63/// behavior is to hand back whatever JSON it finds rather than try to
64/// interpret it.
65pub fn decode_message(encoded: &str) -> Result<String, &'static str> {
66    let compressed = base64::engine::general_purpose::STANDARD
67        .decode(encoded.as_bytes())
68        .map_err(|_| "EncodedMessage is not valid base64")?;
69    let mut decoder = ZlibDecoder::new(&compressed[..]);
70    let mut json_bytes = Vec::new();
71    decoder
72        .read_to_end(&mut json_bytes)
73        .map_err(|_| "EncodedMessage is not a valid deny token")?;
74    String::from_utf8(json_bytes).map_err(|_| "EncodedMessage payload is not valid UTF-8")
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn round_trip_explicit_deny() {
83        let token = encode_deny(
84            true,
85            Some("s3:GetObject"),
86            Some("arn:aws:iam::111122223333:user/alice"),
87            vec![json!({"sourcePolicyId": "PolicyA"})],
88            Some(json!({"aws:SourceIp": "1.2.3.4"})),
89        );
90        let decoded = decode_message(&token).unwrap();
91        let parsed: Value = serde_json::from_str(&decoded).unwrap();
92        assert_eq!(parsed["allowed"], false);
93        assert_eq!(parsed["explicitDeny"], true);
94        assert_eq!(parsed["action"], "s3:GetObject");
95        assert_eq!(parsed["principal"], "arn:aws:iam::111122223333:user/alice");
96        assert_eq!(
97            parsed["matchedStatements"]["items"][0]["sourcePolicyId"],
98            "PolicyA"
99        );
100    }
101
102    #[test]
103    fn round_trip_implicit_deny_with_no_extras() {
104        let token = encode_deny(false, None, None, Vec::new(), None);
105        let decoded = decode_message(&token).unwrap();
106        let parsed: Value = serde_json::from_str(&decoded).unwrap();
107        assert_eq!(parsed["allowed"], false);
108        assert_eq!(parsed["explicitDeny"], false);
109        assert!(parsed["matchedStatements"]["items"]
110            .as_array()
111            .unwrap()
112            .is_empty());
113        assert!(parsed.get("action").is_none());
114    }
115
116    #[test]
117    fn rejects_garbage_base64() {
118        assert!(decode_message("not!!!base64!!").is_err());
119    }
120
121    #[test]
122    fn rejects_base64_that_is_not_zlib() {
123        let token = base64::engine::general_purpose::STANDARD.encode(b"not zlib data");
124        assert!(decode_message(&token).is_err());
125    }
126}