diff --git a/Cargo.lock b/Cargo.lock index b35d2c098..aa6f2061c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1378,6 +1378,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "unicode-properties", "uuid", ] diff --git a/crates/auth/src/cache_registry.rs b/crates/auth/src/cache_registry.rs index 16268df28..5dd5c21b6 100644 --- a/crates/auth/src/cache_registry.rs +++ b/crates/auth/src/cache_registry.rs @@ -72,6 +72,12 @@ pub trait AuthzCacheInvalidator: Send + Sync { user_name: &'a str, ) -> BoxFuture<'a, ()>; + fn invalidate_user_created_at<'a>( + &'a self, + account_id: &'a str, + user_name: &'a str, + ) -> BoxFuture<'a, ()>; + fn invalidate_role_policies<'a>( &'a self, account_id: &'a str, @@ -90,6 +96,12 @@ pub trait AuthzCacheInvalidator: Send + Sync { role_name: &'a str, ) -> BoxFuture<'a, ()>; + fn invalidate_role_created_at<'a>( + &'a self, + account_id: &'a str, + role_name: &'a str, + ) -> BoxFuture<'a, ()>; + fn invalidate_session<'a>( &'a self, account_id: &'a str, @@ -228,6 +240,12 @@ impl AuthCacheRegistry { } } + pub async fn invalidate_user_created_at(&self, account_id: &str, user_name: &str) { + if let Some(c) = &self.authz { + c.invalidate_user_created_at(account_id, user_name).await; + } + } + pub async fn invalidate_role_policies(&self, account_id: &str, role_name: &str) { if let Some(c) = &self.authz { c.invalidate_role_policies(account_id, role_name).await; @@ -246,6 +264,12 @@ impl AuthCacheRegistry { } } + pub async fn invalidate_role_created_at(&self, account_id: &str, role_name: &str) { + if let Some(c) = &self.authz { + c.invalidate_role_created_at(account_id, role_name).await; + } + } + pub async fn invalidate_session(&self, account_id: &str, role_name: &str, session_name: &str) { if let Some(c) = &self.authz { c.invalidate_session(account_id, role_name, session_name) diff --git a/crates/auth/src/policy/casefold.rs b/crates/auth/src/policy/casefold.rs new file mode 100644 index 000000000..e9d7502a7 --- /dev/null +++ b/crates/auth/src/policy/casefold.rs @@ -0,0 +1,1641 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Unicode full case folding (the Case Folding data's full `C` + `F` +//! mappings). The live service resolves a condition key name and a tag key +//! on the `DynamoDB` request path under full case folding, so a name spelled +//! with a non-ASCII letter that folds to a resolvable key (long s to s, the +//! st ligature to st, sharp s to ss) is matched. The dotless i (U+0131) and +//! the dotted I (U+0130) do not fold to a plain ASCII `i` and so read as +//! absent, matching the service. +//! +//! Unicode version: 15.0.0. Generated by `devtools/generate-casefold-table` +//! from Python's `str.casefold`; do not edit by hand. Regenerate with +//! `devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs`. + +/// Code point to its full case fold. Code points not listed fold to +/// themselves. Sorted by code point for binary search. +pub(crate) static CASE_FOLD: &[(u32, &str)] = &[ + (0x41, "\u{61}"), + (0x42, "\u{62}"), + (0x43, "\u{63}"), + (0x44, "\u{64}"), + (0x45, "\u{65}"), + (0x46, "\u{66}"), + (0x47, "\u{67}"), + (0x48, "\u{68}"), + (0x49, "\u{69}"), + (0x4A, "\u{6a}"), + (0x4B, "\u{6b}"), + (0x4C, "\u{6c}"), + (0x4D, "\u{6d}"), + (0x4E, "\u{6e}"), + (0x4F, "\u{6f}"), + (0x50, "\u{70}"), + (0x51, "\u{71}"), + (0x52, "\u{72}"), + (0x53, "\u{73}"), + (0x54, "\u{74}"), + (0x55, "\u{75}"), + (0x56, "\u{76}"), + (0x57, "\u{77}"), + (0x58, "\u{78}"), + (0x59, "\u{79}"), + (0x5A, "\u{7a}"), + (0xB5, "\u{3bc}"), + (0xC0, "\u{e0}"), + (0xC1, "\u{e1}"), + (0xC2, "\u{e2}"), + (0xC3, "\u{e3}"), + (0xC4, "\u{e4}"), + (0xC5, "\u{e5}"), + (0xC6, "\u{e6}"), + (0xC7, "\u{e7}"), + (0xC8, "\u{e8}"), + (0xC9, "\u{e9}"), + (0xCA, "\u{ea}"), + (0xCB, "\u{eb}"), + (0xCC, "\u{ec}"), + (0xCD, "\u{ed}"), + (0xCE, "\u{ee}"), + (0xCF, "\u{ef}"), + (0xD0, "\u{f0}"), + (0xD1, "\u{f1}"), + (0xD2, "\u{f2}"), + (0xD3, "\u{f3}"), + (0xD4, "\u{f4}"), + (0xD5, "\u{f5}"), + (0xD6, "\u{f6}"), + (0xD8, "\u{f8}"), + (0xD9, "\u{f9}"), + (0xDA, "\u{fa}"), + (0xDB, "\u{fb}"), + (0xDC, "\u{fc}"), + (0xDD, "\u{fd}"), + (0xDE, "\u{fe}"), + (0xDF, "\u{73}\u{73}"), + (0x100, "\u{101}"), + (0x102, "\u{103}"), + (0x104, "\u{105}"), + (0x106, "\u{107}"), + (0x108, "\u{109}"), + (0x10A, "\u{10b}"), + (0x10C, "\u{10d}"), + (0x10E, "\u{10f}"), + (0x110, "\u{111}"), + (0x112, "\u{113}"), + (0x114, "\u{115}"), + (0x116, "\u{117}"), + (0x118, "\u{119}"), + (0x11A, "\u{11b}"), + (0x11C, "\u{11d}"), + (0x11E, "\u{11f}"), + (0x120, "\u{121}"), + (0x122, "\u{123}"), + (0x124, "\u{125}"), + (0x126, "\u{127}"), + (0x128, "\u{129}"), + (0x12A, "\u{12b}"), + (0x12C, "\u{12d}"), + (0x12E, "\u{12f}"), + (0x130, "\u{69}\u{307}"), + (0x132, "\u{133}"), + (0x134, "\u{135}"), + (0x136, "\u{137}"), + (0x139, "\u{13a}"), + (0x13B, "\u{13c}"), + (0x13D, "\u{13e}"), + (0x13F, "\u{140}"), + (0x141, "\u{142}"), + (0x143, "\u{144}"), + (0x145, "\u{146}"), + (0x147, "\u{148}"), + (0x149, "\u{2bc}\u{6e}"), + (0x14A, "\u{14b}"), + (0x14C, "\u{14d}"), + (0x14E, "\u{14f}"), + (0x150, "\u{151}"), + (0x152, "\u{153}"), + (0x154, "\u{155}"), + (0x156, "\u{157}"), + (0x158, "\u{159}"), + (0x15A, "\u{15b}"), + (0x15C, "\u{15d}"), + (0x15E, "\u{15f}"), + (0x160, "\u{161}"), + (0x162, "\u{163}"), + (0x164, "\u{165}"), + (0x166, "\u{167}"), + (0x168, "\u{169}"), + (0x16A, "\u{16b}"), + (0x16C, "\u{16d}"), + (0x16E, "\u{16f}"), + (0x170, "\u{171}"), + (0x172, "\u{173}"), + (0x174, "\u{175}"), + (0x176, "\u{177}"), + (0x178, "\u{ff}"), + (0x179, "\u{17a}"), + (0x17B, "\u{17c}"), + (0x17D, "\u{17e}"), + (0x17F, "\u{73}"), + (0x181, "\u{253}"), + (0x182, "\u{183}"), + (0x184, "\u{185}"), + (0x186, "\u{254}"), + (0x187, "\u{188}"), + (0x189, "\u{256}"), + (0x18A, "\u{257}"), + (0x18B, "\u{18c}"), + (0x18E, "\u{1dd}"), + (0x18F, "\u{259}"), + (0x190, "\u{25b}"), + (0x191, "\u{192}"), + (0x193, "\u{260}"), + (0x194, "\u{263}"), + (0x196, "\u{269}"), + (0x197, "\u{268}"), + (0x198, "\u{199}"), + (0x19C, "\u{26f}"), + (0x19D, "\u{272}"), + (0x19F, "\u{275}"), + (0x1A0, "\u{1a1}"), + (0x1A2, "\u{1a3}"), + (0x1A4, "\u{1a5}"), + (0x1A6, "\u{280}"), + (0x1A7, "\u{1a8}"), + (0x1A9, "\u{283}"), + (0x1AC, "\u{1ad}"), + (0x1AE, "\u{288}"), + (0x1AF, "\u{1b0}"), + (0x1B1, "\u{28a}"), + (0x1B2, "\u{28b}"), + (0x1B3, "\u{1b4}"), + (0x1B5, "\u{1b6}"), + (0x1B7, "\u{292}"), + (0x1B8, "\u{1b9}"), + (0x1BC, "\u{1bd}"), + (0x1C4, "\u{1c6}"), + (0x1C5, "\u{1c6}"), + (0x1C7, "\u{1c9}"), + (0x1C8, "\u{1c9}"), + (0x1CA, "\u{1cc}"), + (0x1CB, "\u{1cc}"), + (0x1CD, "\u{1ce}"), + (0x1CF, "\u{1d0}"), + (0x1D1, "\u{1d2}"), + (0x1D3, "\u{1d4}"), + (0x1D5, "\u{1d6}"), + (0x1D7, "\u{1d8}"), + (0x1D9, "\u{1da}"), + (0x1DB, "\u{1dc}"), + (0x1DE, "\u{1df}"), + (0x1E0, "\u{1e1}"), + (0x1E2, "\u{1e3}"), + (0x1E4, "\u{1e5}"), + (0x1E6, "\u{1e7}"), + (0x1E8, "\u{1e9}"), + (0x1EA, "\u{1eb}"), + (0x1EC, "\u{1ed}"), + (0x1EE, "\u{1ef}"), + (0x1F0, "\u{6a}\u{30c}"), + (0x1F1, "\u{1f3}"), + (0x1F2, "\u{1f3}"), + (0x1F4, "\u{1f5}"), + (0x1F6, "\u{195}"), + (0x1F7, "\u{1bf}"), + (0x1F8, "\u{1f9}"), + (0x1FA, "\u{1fb}"), + (0x1FC, "\u{1fd}"), + (0x1FE, "\u{1ff}"), + (0x200, "\u{201}"), + (0x202, "\u{203}"), + (0x204, "\u{205}"), + (0x206, "\u{207}"), + (0x208, "\u{209}"), + (0x20A, "\u{20b}"), + (0x20C, "\u{20d}"), + (0x20E, "\u{20f}"), + (0x210, "\u{211}"), + (0x212, "\u{213}"), + (0x214, "\u{215}"), + (0x216, "\u{217}"), + (0x218, "\u{219}"), + (0x21A, "\u{21b}"), + (0x21C, "\u{21d}"), + (0x21E, "\u{21f}"), + (0x220, "\u{19e}"), + (0x222, "\u{223}"), + (0x224, "\u{225}"), + (0x226, "\u{227}"), + (0x228, "\u{229}"), + (0x22A, "\u{22b}"), + (0x22C, "\u{22d}"), + (0x22E, "\u{22f}"), + (0x230, "\u{231}"), + (0x232, "\u{233}"), + (0x23A, "\u{2c65}"), + (0x23B, "\u{23c}"), + (0x23D, "\u{19a}"), + (0x23E, "\u{2c66}"), + (0x241, "\u{242}"), + (0x243, "\u{180}"), + (0x244, "\u{289}"), + (0x245, "\u{28c}"), + (0x246, "\u{247}"), + (0x248, "\u{249}"), + (0x24A, "\u{24b}"), + (0x24C, "\u{24d}"), + (0x24E, "\u{24f}"), + (0x345, "\u{3b9}"), + (0x370, "\u{371}"), + (0x372, "\u{373}"), + (0x376, "\u{377}"), + (0x37F, "\u{3f3}"), + (0x386, "\u{3ac}"), + (0x388, "\u{3ad}"), + (0x389, "\u{3ae}"), + (0x38A, "\u{3af}"), + (0x38C, "\u{3cc}"), + (0x38E, "\u{3cd}"), + (0x38F, "\u{3ce}"), + (0x390, "\u{3b9}\u{308}\u{301}"), + (0x391, "\u{3b1}"), + (0x392, "\u{3b2}"), + (0x393, "\u{3b3}"), + (0x394, "\u{3b4}"), + (0x395, "\u{3b5}"), + (0x396, "\u{3b6}"), + (0x397, "\u{3b7}"), + (0x398, "\u{3b8}"), + (0x399, "\u{3b9}"), + (0x39A, "\u{3ba}"), + (0x39B, "\u{3bb}"), + (0x39C, "\u{3bc}"), + (0x39D, "\u{3bd}"), + (0x39E, "\u{3be}"), + (0x39F, "\u{3bf}"), + (0x3A0, "\u{3c0}"), + (0x3A1, "\u{3c1}"), + (0x3A3, "\u{3c3}"), + (0x3A4, "\u{3c4}"), + (0x3A5, "\u{3c5}"), + (0x3A6, "\u{3c6}"), + (0x3A7, "\u{3c7}"), + (0x3A8, "\u{3c8}"), + (0x3A9, "\u{3c9}"), + (0x3AA, "\u{3ca}"), + (0x3AB, "\u{3cb}"), + (0x3B0, "\u{3c5}\u{308}\u{301}"), + (0x3C2, "\u{3c3}"), + (0x3CF, "\u{3d7}"), + (0x3D0, "\u{3b2}"), + (0x3D1, "\u{3b8}"), + (0x3D5, "\u{3c6}"), + (0x3D6, "\u{3c0}"), + (0x3D8, "\u{3d9}"), + (0x3DA, "\u{3db}"), + (0x3DC, "\u{3dd}"), + (0x3DE, "\u{3df}"), + (0x3E0, "\u{3e1}"), + (0x3E2, "\u{3e3}"), + (0x3E4, "\u{3e5}"), + (0x3E6, "\u{3e7}"), + (0x3E8, "\u{3e9}"), + (0x3EA, "\u{3eb}"), + (0x3EC, "\u{3ed}"), + (0x3EE, "\u{3ef}"), + (0x3F0, "\u{3ba}"), + (0x3F1, "\u{3c1}"), + (0x3F4, "\u{3b8}"), + (0x3F5, "\u{3b5}"), + (0x3F7, "\u{3f8}"), + (0x3F9, "\u{3f2}"), + (0x3FA, "\u{3fb}"), + (0x3FD, "\u{37b}"), + (0x3FE, "\u{37c}"), + (0x3FF, "\u{37d}"), + (0x400, "\u{450}"), + (0x401, "\u{451}"), + (0x402, "\u{452}"), + (0x403, "\u{453}"), + (0x404, "\u{454}"), + (0x405, "\u{455}"), + (0x406, "\u{456}"), + (0x407, "\u{457}"), + (0x408, "\u{458}"), + (0x409, "\u{459}"), + (0x40A, "\u{45a}"), + (0x40B, "\u{45b}"), + (0x40C, "\u{45c}"), + (0x40D, "\u{45d}"), + (0x40E, "\u{45e}"), + (0x40F, "\u{45f}"), + (0x410, "\u{430}"), + (0x411, "\u{431}"), + (0x412, "\u{432}"), + (0x413, "\u{433}"), + (0x414, "\u{434}"), + (0x415, "\u{435}"), + (0x416, "\u{436}"), + (0x417, "\u{437}"), + (0x418, "\u{438}"), + (0x419, "\u{439}"), + (0x41A, "\u{43a}"), + (0x41B, "\u{43b}"), + (0x41C, "\u{43c}"), + (0x41D, "\u{43d}"), + (0x41E, "\u{43e}"), + (0x41F, "\u{43f}"), + (0x420, "\u{440}"), + (0x421, "\u{441}"), + (0x422, "\u{442}"), + (0x423, "\u{443}"), + (0x424, "\u{444}"), + (0x425, "\u{445}"), + (0x426, "\u{446}"), + (0x427, "\u{447}"), + (0x428, "\u{448}"), + (0x429, "\u{449}"), + (0x42A, "\u{44a}"), + (0x42B, "\u{44b}"), + (0x42C, "\u{44c}"), + (0x42D, "\u{44d}"), + (0x42E, "\u{44e}"), + (0x42F, "\u{44f}"), + (0x460, "\u{461}"), + (0x462, "\u{463}"), + (0x464, "\u{465}"), + (0x466, "\u{467}"), + (0x468, "\u{469}"), + (0x46A, "\u{46b}"), + (0x46C, "\u{46d}"), + (0x46E, "\u{46f}"), + (0x470, "\u{471}"), + (0x472, "\u{473}"), + (0x474, "\u{475}"), + (0x476, "\u{477}"), + (0x478, "\u{479}"), + (0x47A, "\u{47b}"), + (0x47C, "\u{47d}"), + (0x47E, "\u{47f}"), + (0x480, "\u{481}"), + (0x48A, "\u{48b}"), + (0x48C, "\u{48d}"), + (0x48E, "\u{48f}"), + (0x490, "\u{491}"), + (0x492, "\u{493}"), + (0x494, "\u{495}"), + (0x496, "\u{497}"), + (0x498, "\u{499}"), + (0x49A, "\u{49b}"), + (0x49C, "\u{49d}"), + (0x49E, "\u{49f}"), + (0x4A0, "\u{4a1}"), + (0x4A2, "\u{4a3}"), + (0x4A4, "\u{4a5}"), + (0x4A6, "\u{4a7}"), + (0x4A8, "\u{4a9}"), + (0x4AA, "\u{4ab}"), + (0x4AC, "\u{4ad}"), + (0x4AE, "\u{4af}"), + (0x4B0, "\u{4b1}"), + (0x4B2, "\u{4b3}"), + (0x4B4, "\u{4b5}"), + (0x4B6, "\u{4b7}"), + (0x4B8, "\u{4b9}"), + (0x4BA, "\u{4bb}"), + (0x4BC, "\u{4bd}"), + (0x4BE, "\u{4bf}"), + (0x4C0, "\u{4cf}"), + (0x4C1, "\u{4c2}"), + (0x4C3, "\u{4c4}"), + (0x4C5, "\u{4c6}"), + (0x4C7, "\u{4c8}"), + (0x4C9, "\u{4ca}"), + (0x4CB, "\u{4cc}"), + (0x4CD, "\u{4ce}"), + (0x4D0, "\u{4d1}"), + (0x4D2, "\u{4d3}"), + (0x4D4, "\u{4d5}"), + (0x4D6, "\u{4d7}"), + (0x4D8, "\u{4d9}"), + (0x4DA, "\u{4db}"), + (0x4DC, "\u{4dd}"), + (0x4DE, "\u{4df}"), + (0x4E0, "\u{4e1}"), + (0x4E2, "\u{4e3}"), + (0x4E4, "\u{4e5}"), + (0x4E6, "\u{4e7}"), + (0x4E8, "\u{4e9}"), + (0x4EA, "\u{4eb}"), + (0x4EC, "\u{4ed}"), + (0x4EE, "\u{4ef}"), + (0x4F0, "\u{4f1}"), + (0x4F2, "\u{4f3}"), + (0x4F4, "\u{4f5}"), + (0x4F6, "\u{4f7}"), + (0x4F8, "\u{4f9}"), + (0x4FA, "\u{4fb}"), + (0x4FC, "\u{4fd}"), + (0x4FE, "\u{4ff}"), + (0x500, "\u{501}"), + (0x502, "\u{503}"), + (0x504, "\u{505}"), + (0x506, "\u{507}"), + (0x508, "\u{509}"), + (0x50A, "\u{50b}"), + (0x50C, "\u{50d}"), + (0x50E, "\u{50f}"), + (0x510, "\u{511}"), + (0x512, "\u{513}"), + (0x514, "\u{515}"), + (0x516, "\u{517}"), + (0x518, "\u{519}"), + (0x51A, "\u{51b}"), + (0x51C, "\u{51d}"), + (0x51E, "\u{51f}"), + (0x520, "\u{521}"), + (0x522, "\u{523}"), + (0x524, "\u{525}"), + (0x526, "\u{527}"), + (0x528, "\u{529}"), + (0x52A, "\u{52b}"), + (0x52C, "\u{52d}"), + (0x52E, "\u{52f}"), + (0x531, "\u{561}"), + (0x532, "\u{562}"), + (0x533, "\u{563}"), + (0x534, "\u{564}"), + (0x535, "\u{565}"), + (0x536, "\u{566}"), + (0x537, "\u{567}"), + (0x538, "\u{568}"), + (0x539, "\u{569}"), + (0x53A, "\u{56a}"), + (0x53B, "\u{56b}"), + (0x53C, "\u{56c}"), + (0x53D, "\u{56d}"), + (0x53E, "\u{56e}"), + (0x53F, "\u{56f}"), + (0x540, "\u{570}"), + (0x541, "\u{571}"), + (0x542, "\u{572}"), + (0x543, "\u{573}"), + (0x544, "\u{574}"), + (0x545, "\u{575}"), + (0x546, "\u{576}"), + (0x547, "\u{577}"), + (0x548, "\u{578}"), + (0x549, "\u{579}"), + (0x54A, "\u{57a}"), + (0x54B, "\u{57b}"), + (0x54C, "\u{57c}"), + (0x54D, "\u{57d}"), + (0x54E, "\u{57e}"), + (0x54F, "\u{57f}"), + (0x550, "\u{580}"), + (0x551, "\u{581}"), + (0x552, "\u{582}"), + (0x553, "\u{583}"), + (0x554, "\u{584}"), + (0x555, "\u{585}"), + (0x556, "\u{586}"), + (0x587, "\u{565}\u{582}"), + (0x10A0, "\u{2d00}"), + (0x10A1, "\u{2d01}"), + (0x10A2, "\u{2d02}"), + (0x10A3, "\u{2d03}"), + (0x10A4, "\u{2d04}"), + (0x10A5, "\u{2d05}"), + (0x10A6, "\u{2d06}"), + (0x10A7, "\u{2d07}"), + (0x10A8, "\u{2d08}"), + (0x10A9, "\u{2d09}"), + (0x10AA, "\u{2d0a}"), + (0x10AB, "\u{2d0b}"), + (0x10AC, "\u{2d0c}"), + (0x10AD, "\u{2d0d}"), + (0x10AE, "\u{2d0e}"), + (0x10AF, "\u{2d0f}"), + (0x10B0, "\u{2d10}"), + (0x10B1, "\u{2d11}"), + (0x10B2, "\u{2d12}"), + (0x10B3, "\u{2d13}"), + (0x10B4, "\u{2d14}"), + (0x10B5, "\u{2d15}"), + (0x10B6, "\u{2d16}"), + (0x10B7, "\u{2d17}"), + (0x10B8, "\u{2d18}"), + (0x10B9, "\u{2d19}"), + (0x10BA, "\u{2d1a}"), + (0x10BB, "\u{2d1b}"), + (0x10BC, "\u{2d1c}"), + (0x10BD, "\u{2d1d}"), + (0x10BE, "\u{2d1e}"), + (0x10BF, "\u{2d1f}"), + (0x10C0, "\u{2d20}"), + (0x10C1, "\u{2d21}"), + (0x10C2, "\u{2d22}"), + (0x10C3, "\u{2d23}"), + (0x10C4, "\u{2d24}"), + (0x10C5, "\u{2d25}"), + (0x10C7, "\u{2d27}"), + (0x10CD, "\u{2d2d}"), + (0x13F8, "\u{13f0}"), + (0x13F9, "\u{13f1}"), + (0x13FA, "\u{13f2}"), + (0x13FB, "\u{13f3}"), + (0x13FC, "\u{13f4}"), + (0x13FD, "\u{13f5}"), + (0x1C80, "\u{432}"), + (0x1C81, "\u{434}"), + (0x1C82, "\u{43e}"), + (0x1C83, "\u{441}"), + (0x1C84, "\u{442}"), + (0x1C85, "\u{442}"), + (0x1C86, "\u{44a}"), + (0x1C87, "\u{463}"), + (0x1C88, "\u{a64b}"), + (0x1C90, "\u{10d0}"), + (0x1C91, "\u{10d1}"), + (0x1C92, "\u{10d2}"), + (0x1C93, "\u{10d3}"), + (0x1C94, "\u{10d4}"), + (0x1C95, "\u{10d5}"), + (0x1C96, "\u{10d6}"), + (0x1C97, "\u{10d7}"), + (0x1C98, "\u{10d8}"), + (0x1C99, "\u{10d9}"), + (0x1C9A, "\u{10da}"), + (0x1C9B, "\u{10db}"), + (0x1C9C, "\u{10dc}"), + (0x1C9D, "\u{10dd}"), + (0x1C9E, "\u{10de}"), + (0x1C9F, "\u{10df}"), + (0x1CA0, "\u{10e0}"), + (0x1CA1, "\u{10e1}"), + (0x1CA2, "\u{10e2}"), + (0x1CA3, "\u{10e3}"), + (0x1CA4, "\u{10e4}"), + (0x1CA5, "\u{10e5}"), + (0x1CA6, "\u{10e6}"), + (0x1CA7, "\u{10e7}"), + (0x1CA8, "\u{10e8}"), + (0x1CA9, "\u{10e9}"), + (0x1CAA, "\u{10ea}"), + (0x1CAB, "\u{10eb}"), + (0x1CAC, "\u{10ec}"), + (0x1CAD, "\u{10ed}"), + (0x1CAE, "\u{10ee}"), + (0x1CAF, "\u{10ef}"), + (0x1CB0, "\u{10f0}"), + (0x1CB1, "\u{10f1}"), + (0x1CB2, "\u{10f2}"), + (0x1CB3, "\u{10f3}"), + (0x1CB4, "\u{10f4}"), + (0x1CB5, "\u{10f5}"), + (0x1CB6, "\u{10f6}"), + (0x1CB7, "\u{10f7}"), + (0x1CB8, "\u{10f8}"), + (0x1CB9, "\u{10f9}"), + (0x1CBA, "\u{10fa}"), + (0x1CBD, "\u{10fd}"), + (0x1CBE, "\u{10fe}"), + (0x1CBF, "\u{10ff}"), + (0x1E00, "\u{1e01}"), + (0x1E02, "\u{1e03}"), + (0x1E04, "\u{1e05}"), + (0x1E06, "\u{1e07}"), + (0x1E08, "\u{1e09}"), + (0x1E0A, "\u{1e0b}"), + (0x1E0C, "\u{1e0d}"), + (0x1E0E, "\u{1e0f}"), + (0x1E10, "\u{1e11}"), + (0x1E12, "\u{1e13}"), + (0x1E14, "\u{1e15}"), + (0x1E16, "\u{1e17}"), + (0x1E18, "\u{1e19}"), + (0x1E1A, "\u{1e1b}"), + (0x1E1C, "\u{1e1d}"), + (0x1E1E, "\u{1e1f}"), + (0x1E20, "\u{1e21}"), + (0x1E22, "\u{1e23}"), + (0x1E24, "\u{1e25}"), + (0x1E26, "\u{1e27}"), + (0x1E28, "\u{1e29}"), + (0x1E2A, "\u{1e2b}"), + (0x1E2C, "\u{1e2d}"), + (0x1E2E, "\u{1e2f}"), + (0x1E30, "\u{1e31}"), + (0x1E32, "\u{1e33}"), + (0x1E34, "\u{1e35}"), + (0x1E36, "\u{1e37}"), + (0x1E38, "\u{1e39}"), + (0x1E3A, "\u{1e3b}"), + (0x1E3C, "\u{1e3d}"), + (0x1E3E, "\u{1e3f}"), + (0x1E40, "\u{1e41}"), + (0x1E42, "\u{1e43}"), + (0x1E44, "\u{1e45}"), + (0x1E46, "\u{1e47}"), + (0x1E48, "\u{1e49}"), + (0x1E4A, "\u{1e4b}"), + (0x1E4C, "\u{1e4d}"), + (0x1E4E, "\u{1e4f}"), + (0x1E50, "\u{1e51}"), + (0x1E52, "\u{1e53}"), + (0x1E54, "\u{1e55}"), + (0x1E56, "\u{1e57}"), + (0x1E58, "\u{1e59}"), + (0x1E5A, "\u{1e5b}"), + (0x1E5C, "\u{1e5d}"), + (0x1E5E, "\u{1e5f}"), + (0x1E60, "\u{1e61}"), + (0x1E62, "\u{1e63}"), + (0x1E64, "\u{1e65}"), + (0x1E66, "\u{1e67}"), + (0x1E68, "\u{1e69}"), + (0x1E6A, "\u{1e6b}"), + (0x1E6C, "\u{1e6d}"), + (0x1E6E, "\u{1e6f}"), + (0x1E70, "\u{1e71}"), + (0x1E72, "\u{1e73}"), + (0x1E74, "\u{1e75}"), + (0x1E76, "\u{1e77}"), + (0x1E78, "\u{1e79}"), + (0x1E7A, "\u{1e7b}"), + (0x1E7C, "\u{1e7d}"), + (0x1E7E, "\u{1e7f}"), + (0x1E80, "\u{1e81}"), + (0x1E82, "\u{1e83}"), + (0x1E84, "\u{1e85}"), + (0x1E86, "\u{1e87}"), + (0x1E88, "\u{1e89}"), + (0x1E8A, "\u{1e8b}"), + (0x1E8C, "\u{1e8d}"), + (0x1E8E, "\u{1e8f}"), + (0x1E90, "\u{1e91}"), + (0x1E92, "\u{1e93}"), + (0x1E94, "\u{1e95}"), + (0x1E96, "\u{68}\u{331}"), + (0x1E97, "\u{74}\u{308}"), + (0x1E98, "\u{77}\u{30a}"), + (0x1E99, "\u{79}\u{30a}"), + (0x1E9A, "\u{61}\u{2be}"), + (0x1E9B, "\u{1e61}"), + (0x1E9E, "\u{73}\u{73}"), + (0x1EA0, "\u{1ea1}"), + (0x1EA2, "\u{1ea3}"), + (0x1EA4, "\u{1ea5}"), + (0x1EA6, "\u{1ea7}"), + (0x1EA8, "\u{1ea9}"), + (0x1EAA, "\u{1eab}"), + (0x1EAC, "\u{1ead}"), + (0x1EAE, "\u{1eaf}"), + (0x1EB0, "\u{1eb1}"), + (0x1EB2, "\u{1eb3}"), + (0x1EB4, "\u{1eb5}"), + (0x1EB6, "\u{1eb7}"), + (0x1EB8, "\u{1eb9}"), + (0x1EBA, "\u{1ebb}"), + (0x1EBC, "\u{1ebd}"), + (0x1EBE, "\u{1ebf}"), + (0x1EC0, "\u{1ec1}"), + (0x1EC2, "\u{1ec3}"), + (0x1EC4, "\u{1ec5}"), + (0x1EC6, "\u{1ec7}"), + (0x1EC8, "\u{1ec9}"), + (0x1ECA, "\u{1ecb}"), + (0x1ECC, "\u{1ecd}"), + (0x1ECE, "\u{1ecf}"), + (0x1ED0, "\u{1ed1}"), + (0x1ED2, "\u{1ed3}"), + (0x1ED4, "\u{1ed5}"), + (0x1ED6, "\u{1ed7}"), + (0x1ED8, "\u{1ed9}"), + (0x1EDA, "\u{1edb}"), + (0x1EDC, "\u{1edd}"), + (0x1EDE, "\u{1edf}"), + (0x1EE0, "\u{1ee1}"), + (0x1EE2, "\u{1ee3}"), + (0x1EE4, "\u{1ee5}"), + (0x1EE6, "\u{1ee7}"), + (0x1EE8, "\u{1ee9}"), + (0x1EEA, "\u{1eeb}"), + (0x1EEC, "\u{1eed}"), + (0x1EEE, "\u{1eef}"), + (0x1EF0, "\u{1ef1}"), + (0x1EF2, "\u{1ef3}"), + (0x1EF4, "\u{1ef5}"), + (0x1EF6, "\u{1ef7}"), + (0x1EF8, "\u{1ef9}"), + (0x1EFA, "\u{1efb}"), + (0x1EFC, "\u{1efd}"), + (0x1EFE, "\u{1eff}"), + (0x1F08, "\u{1f00}"), + (0x1F09, "\u{1f01}"), + (0x1F0A, "\u{1f02}"), + (0x1F0B, "\u{1f03}"), + (0x1F0C, "\u{1f04}"), + (0x1F0D, "\u{1f05}"), + (0x1F0E, "\u{1f06}"), + (0x1F0F, "\u{1f07}"), + (0x1F18, "\u{1f10}"), + (0x1F19, "\u{1f11}"), + (0x1F1A, "\u{1f12}"), + (0x1F1B, "\u{1f13}"), + (0x1F1C, "\u{1f14}"), + (0x1F1D, "\u{1f15}"), + (0x1F28, "\u{1f20}"), + (0x1F29, "\u{1f21}"), + (0x1F2A, "\u{1f22}"), + (0x1F2B, "\u{1f23}"), + (0x1F2C, "\u{1f24}"), + (0x1F2D, "\u{1f25}"), + (0x1F2E, "\u{1f26}"), + (0x1F2F, "\u{1f27}"), + (0x1F38, "\u{1f30}"), + (0x1F39, "\u{1f31}"), + (0x1F3A, "\u{1f32}"), + (0x1F3B, "\u{1f33}"), + (0x1F3C, "\u{1f34}"), + (0x1F3D, "\u{1f35}"), + (0x1F3E, "\u{1f36}"), + (0x1F3F, "\u{1f37}"), + (0x1F48, "\u{1f40}"), + (0x1F49, "\u{1f41}"), + (0x1F4A, "\u{1f42}"), + (0x1F4B, "\u{1f43}"), + (0x1F4C, "\u{1f44}"), + (0x1F4D, "\u{1f45}"), + (0x1F50, "\u{3c5}\u{313}"), + (0x1F52, "\u{3c5}\u{313}\u{300}"), + (0x1F54, "\u{3c5}\u{313}\u{301}"), + (0x1F56, "\u{3c5}\u{313}\u{342}"), + (0x1F59, "\u{1f51}"), + (0x1F5B, "\u{1f53}"), + (0x1F5D, "\u{1f55}"), + (0x1F5F, "\u{1f57}"), + (0x1F68, "\u{1f60}"), + (0x1F69, "\u{1f61}"), + (0x1F6A, "\u{1f62}"), + (0x1F6B, "\u{1f63}"), + (0x1F6C, "\u{1f64}"), + (0x1F6D, "\u{1f65}"), + (0x1F6E, "\u{1f66}"), + (0x1F6F, "\u{1f67}"), + (0x1F80, "\u{1f00}\u{3b9}"), + (0x1F81, "\u{1f01}\u{3b9}"), + (0x1F82, "\u{1f02}\u{3b9}"), + (0x1F83, "\u{1f03}\u{3b9}"), + (0x1F84, "\u{1f04}\u{3b9}"), + (0x1F85, "\u{1f05}\u{3b9}"), + (0x1F86, "\u{1f06}\u{3b9}"), + (0x1F87, "\u{1f07}\u{3b9}"), + (0x1F88, "\u{1f00}\u{3b9}"), + (0x1F89, "\u{1f01}\u{3b9}"), + (0x1F8A, "\u{1f02}\u{3b9}"), + (0x1F8B, "\u{1f03}\u{3b9}"), + (0x1F8C, "\u{1f04}\u{3b9}"), + (0x1F8D, "\u{1f05}\u{3b9}"), + (0x1F8E, "\u{1f06}\u{3b9}"), + (0x1F8F, "\u{1f07}\u{3b9}"), + (0x1F90, "\u{1f20}\u{3b9}"), + (0x1F91, "\u{1f21}\u{3b9}"), + (0x1F92, "\u{1f22}\u{3b9}"), + (0x1F93, "\u{1f23}\u{3b9}"), + (0x1F94, "\u{1f24}\u{3b9}"), + (0x1F95, "\u{1f25}\u{3b9}"), + (0x1F96, "\u{1f26}\u{3b9}"), + (0x1F97, "\u{1f27}\u{3b9}"), + (0x1F98, "\u{1f20}\u{3b9}"), + (0x1F99, "\u{1f21}\u{3b9}"), + (0x1F9A, "\u{1f22}\u{3b9}"), + (0x1F9B, "\u{1f23}\u{3b9}"), + (0x1F9C, "\u{1f24}\u{3b9}"), + (0x1F9D, "\u{1f25}\u{3b9}"), + (0x1F9E, "\u{1f26}\u{3b9}"), + (0x1F9F, "\u{1f27}\u{3b9}"), + (0x1FA0, "\u{1f60}\u{3b9}"), + (0x1FA1, "\u{1f61}\u{3b9}"), + (0x1FA2, "\u{1f62}\u{3b9}"), + (0x1FA3, "\u{1f63}\u{3b9}"), + (0x1FA4, "\u{1f64}\u{3b9}"), + (0x1FA5, "\u{1f65}\u{3b9}"), + (0x1FA6, "\u{1f66}\u{3b9}"), + (0x1FA7, "\u{1f67}\u{3b9}"), + (0x1FA8, "\u{1f60}\u{3b9}"), + (0x1FA9, "\u{1f61}\u{3b9}"), + (0x1FAA, "\u{1f62}\u{3b9}"), + (0x1FAB, "\u{1f63}\u{3b9}"), + (0x1FAC, "\u{1f64}\u{3b9}"), + (0x1FAD, "\u{1f65}\u{3b9}"), + (0x1FAE, "\u{1f66}\u{3b9}"), + (0x1FAF, "\u{1f67}\u{3b9}"), + (0x1FB2, "\u{1f70}\u{3b9}"), + (0x1FB3, "\u{3b1}\u{3b9}"), + (0x1FB4, "\u{3ac}\u{3b9}"), + (0x1FB6, "\u{3b1}\u{342}"), + (0x1FB7, "\u{3b1}\u{342}\u{3b9}"), + (0x1FB8, "\u{1fb0}"), + (0x1FB9, "\u{1fb1}"), + (0x1FBA, "\u{1f70}"), + (0x1FBB, "\u{1f71}"), + (0x1FBC, "\u{3b1}\u{3b9}"), + (0x1FBE, "\u{3b9}"), + (0x1FC2, "\u{1f74}\u{3b9}"), + (0x1FC3, "\u{3b7}\u{3b9}"), + (0x1FC4, "\u{3ae}\u{3b9}"), + (0x1FC6, "\u{3b7}\u{342}"), + (0x1FC7, "\u{3b7}\u{342}\u{3b9}"), + (0x1FC8, "\u{1f72}"), + (0x1FC9, "\u{1f73}"), + (0x1FCA, "\u{1f74}"), + (0x1FCB, "\u{1f75}"), + (0x1FCC, "\u{3b7}\u{3b9}"), + (0x1FD2, "\u{3b9}\u{308}\u{300}"), + (0x1FD3, "\u{3b9}\u{308}\u{301}"), + (0x1FD6, "\u{3b9}\u{342}"), + (0x1FD7, "\u{3b9}\u{308}\u{342}"), + (0x1FD8, "\u{1fd0}"), + (0x1FD9, "\u{1fd1}"), + (0x1FDA, "\u{1f76}"), + (0x1FDB, "\u{1f77}"), + (0x1FE2, "\u{3c5}\u{308}\u{300}"), + (0x1FE3, "\u{3c5}\u{308}\u{301}"), + (0x1FE4, "\u{3c1}\u{313}"), + (0x1FE6, "\u{3c5}\u{342}"), + (0x1FE7, "\u{3c5}\u{308}\u{342}"), + (0x1FE8, "\u{1fe0}"), + (0x1FE9, "\u{1fe1}"), + (0x1FEA, "\u{1f7a}"), + (0x1FEB, "\u{1f7b}"), + (0x1FEC, "\u{1fe5}"), + (0x1FF2, "\u{1f7c}\u{3b9}"), + (0x1FF3, "\u{3c9}\u{3b9}"), + (0x1FF4, "\u{3ce}\u{3b9}"), + (0x1FF6, "\u{3c9}\u{342}"), + (0x1FF7, "\u{3c9}\u{342}\u{3b9}"), + (0x1FF8, "\u{1f78}"), + (0x1FF9, "\u{1f79}"), + (0x1FFA, "\u{1f7c}"), + (0x1FFB, "\u{1f7d}"), + (0x1FFC, "\u{3c9}\u{3b9}"), + (0x2126, "\u{3c9}"), + (0x212A, "\u{6b}"), + (0x212B, "\u{e5}"), + (0x2132, "\u{214e}"), + (0x2160, "\u{2170}"), + (0x2161, "\u{2171}"), + (0x2162, "\u{2172}"), + (0x2163, "\u{2173}"), + (0x2164, "\u{2174}"), + (0x2165, "\u{2175}"), + (0x2166, "\u{2176}"), + (0x2167, "\u{2177}"), + (0x2168, "\u{2178}"), + (0x2169, "\u{2179}"), + (0x216A, "\u{217a}"), + (0x216B, "\u{217b}"), + (0x216C, "\u{217c}"), + (0x216D, "\u{217d}"), + (0x216E, "\u{217e}"), + (0x216F, "\u{217f}"), + (0x2183, "\u{2184}"), + (0x24B6, "\u{24d0}"), + (0x24B7, "\u{24d1}"), + (0x24B8, "\u{24d2}"), + (0x24B9, "\u{24d3}"), + (0x24BA, "\u{24d4}"), + (0x24BB, "\u{24d5}"), + (0x24BC, "\u{24d6}"), + (0x24BD, "\u{24d7}"), + (0x24BE, "\u{24d8}"), + (0x24BF, "\u{24d9}"), + (0x24C0, "\u{24da}"), + (0x24C1, "\u{24db}"), + (0x24C2, "\u{24dc}"), + (0x24C3, "\u{24dd}"), + (0x24C4, "\u{24de}"), + (0x24C5, "\u{24df}"), + (0x24C6, "\u{24e0}"), + (0x24C7, "\u{24e1}"), + (0x24C8, "\u{24e2}"), + (0x24C9, "\u{24e3}"), + (0x24CA, "\u{24e4}"), + (0x24CB, "\u{24e5}"), + (0x24CC, "\u{24e6}"), + (0x24CD, "\u{24e7}"), + (0x24CE, "\u{24e8}"), + (0x24CF, "\u{24e9}"), + (0x2C00, "\u{2c30}"), + (0x2C01, "\u{2c31}"), + (0x2C02, "\u{2c32}"), + (0x2C03, "\u{2c33}"), + (0x2C04, "\u{2c34}"), + (0x2C05, "\u{2c35}"), + (0x2C06, "\u{2c36}"), + (0x2C07, "\u{2c37}"), + (0x2C08, "\u{2c38}"), + (0x2C09, "\u{2c39}"), + (0x2C0A, "\u{2c3a}"), + (0x2C0B, "\u{2c3b}"), + (0x2C0C, "\u{2c3c}"), + (0x2C0D, "\u{2c3d}"), + (0x2C0E, "\u{2c3e}"), + (0x2C0F, "\u{2c3f}"), + (0x2C10, "\u{2c40}"), + (0x2C11, "\u{2c41}"), + (0x2C12, "\u{2c42}"), + (0x2C13, "\u{2c43}"), + (0x2C14, "\u{2c44}"), + (0x2C15, "\u{2c45}"), + (0x2C16, "\u{2c46}"), + (0x2C17, "\u{2c47}"), + (0x2C18, "\u{2c48}"), + (0x2C19, "\u{2c49}"), + (0x2C1A, "\u{2c4a}"), + (0x2C1B, "\u{2c4b}"), + (0x2C1C, "\u{2c4c}"), + (0x2C1D, "\u{2c4d}"), + (0x2C1E, "\u{2c4e}"), + (0x2C1F, "\u{2c4f}"), + (0x2C20, "\u{2c50}"), + (0x2C21, "\u{2c51}"), + (0x2C22, "\u{2c52}"), + (0x2C23, "\u{2c53}"), + (0x2C24, "\u{2c54}"), + (0x2C25, "\u{2c55}"), + (0x2C26, "\u{2c56}"), + (0x2C27, "\u{2c57}"), + (0x2C28, "\u{2c58}"), + (0x2C29, "\u{2c59}"), + (0x2C2A, "\u{2c5a}"), + (0x2C2B, "\u{2c5b}"), + (0x2C2C, "\u{2c5c}"), + (0x2C2D, "\u{2c5d}"), + (0x2C2E, "\u{2c5e}"), + (0x2C2F, "\u{2c5f}"), + (0x2C60, "\u{2c61}"), + (0x2C62, "\u{26b}"), + (0x2C63, "\u{1d7d}"), + (0x2C64, "\u{27d}"), + (0x2C67, "\u{2c68}"), + (0x2C69, "\u{2c6a}"), + (0x2C6B, "\u{2c6c}"), + (0x2C6D, "\u{251}"), + (0x2C6E, "\u{271}"), + (0x2C6F, "\u{250}"), + (0x2C70, "\u{252}"), + (0x2C72, "\u{2c73}"), + (0x2C75, "\u{2c76}"), + (0x2C7E, "\u{23f}"), + (0x2C7F, "\u{240}"), + (0x2C80, "\u{2c81}"), + (0x2C82, "\u{2c83}"), + (0x2C84, "\u{2c85}"), + (0x2C86, "\u{2c87}"), + (0x2C88, "\u{2c89}"), + (0x2C8A, "\u{2c8b}"), + (0x2C8C, "\u{2c8d}"), + (0x2C8E, "\u{2c8f}"), + (0x2C90, "\u{2c91}"), + (0x2C92, "\u{2c93}"), + (0x2C94, "\u{2c95}"), + (0x2C96, "\u{2c97}"), + (0x2C98, "\u{2c99}"), + (0x2C9A, "\u{2c9b}"), + (0x2C9C, "\u{2c9d}"), + (0x2C9E, "\u{2c9f}"), + (0x2CA0, "\u{2ca1}"), + (0x2CA2, "\u{2ca3}"), + (0x2CA4, "\u{2ca5}"), + (0x2CA6, "\u{2ca7}"), + (0x2CA8, "\u{2ca9}"), + (0x2CAA, "\u{2cab}"), + (0x2CAC, "\u{2cad}"), + (0x2CAE, "\u{2caf}"), + (0x2CB0, "\u{2cb1}"), + (0x2CB2, "\u{2cb3}"), + (0x2CB4, "\u{2cb5}"), + (0x2CB6, "\u{2cb7}"), + (0x2CB8, "\u{2cb9}"), + (0x2CBA, "\u{2cbb}"), + (0x2CBC, "\u{2cbd}"), + (0x2CBE, "\u{2cbf}"), + (0x2CC0, "\u{2cc1}"), + (0x2CC2, "\u{2cc3}"), + (0x2CC4, "\u{2cc5}"), + (0x2CC6, "\u{2cc7}"), + (0x2CC8, "\u{2cc9}"), + (0x2CCA, "\u{2ccb}"), + (0x2CCC, "\u{2ccd}"), + (0x2CCE, "\u{2ccf}"), + (0x2CD0, "\u{2cd1}"), + (0x2CD2, "\u{2cd3}"), + (0x2CD4, "\u{2cd5}"), + (0x2CD6, "\u{2cd7}"), + (0x2CD8, "\u{2cd9}"), + (0x2CDA, "\u{2cdb}"), + (0x2CDC, "\u{2cdd}"), + (0x2CDE, "\u{2cdf}"), + (0x2CE0, "\u{2ce1}"), + (0x2CE2, "\u{2ce3}"), + (0x2CEB, "\u{2cec}"), + (0x2CED, "\u{2cee}"), + (0x2CF2, "\u{2cf3}"), + (0xA640, "\u{a641}"), + (0xA642, "\u{a643}"), + (0xA644, "\u{a645}"), + (0xA646, "\u{a647}"), + (0xA648, "\u{a649}"), + (0xA64A, "\u{a64b}"), + (0xA64C, "\u{a64d}"), + (0xA64E, "\u{a64f}"), + (0xA650, "\u{a651}"), + (0xA652, "\u{a653}"), + (0xA654, "\u{a655}"), + (0xA656, "\u{a657}"), + (0xA658, "\u{a659}"), + (0xA65A, "\u{a65b}"), + (0xA65C, "\u{a65d}"), + (0xA65E, "\u{a65f}"), + (0xA660, "\u{a661}"), + (0xA662, "\u{a663}"), + (0xA664, "\u{a665}"), + (0xA666, "\u{a667}"), + (0xA668, "\u{a669}"), + (0xA66A, "\u{a66b}"), + (0xA66C, "\u{a66d}"), + (0xA680, "\u{a681}"), + (0xA682, "\u{a683}"), + (0xA684, "\u{a685}"), + (0xA686, "\u{a687}"), + (0xA688, "\u{a689}"), + (0xA68A, "\u{a68b}"), + (0xA68C, "\u{a68d}"), + (0xA68E, "\u{a68f}"), + (0xA690, "\u{a691}"), + (0xA692, "\u{a693}"), + (0xA694, "\u{a695}"), + (0xA696, "\u{a697}"), + (0xA698, "\u{a699}"), + (0xA69A, "\u{a69b}"), + (0xA722, "\u{a723}"), + (0xA724, "\u{a725}"), + (0xA726, "\u{a727}"), + (0xA728, "\u{a729}"), + (0xA72A, "\u{a72b}"), + (0xA72C, "\u{a72d}"), + (0xA72E, "\u{a72f}"), + (0xA732, "\u{a733}"), + (0xA734, "\u{a735}"), + (0xA736, "\u{a737}"), + (0xA738, "\u{a739}"), + (0xA73A, "\u{a73b}"), + (0xA73C, "\u{a73d}"), + (0xA73E, "\u{a73f}"), + (0xA740, "\u{a741}"), + (0xA742, "\u{a743}"), + (0xA744, "\u{a745}"), + (0xA746, "\u{a747}"), + (0xA748, "\u{a749}"), + (0xA74A, "\u{a74b}"), + (0xA74C, "\u{a74d}"), + (0xA74E, "\u{a74f}"), + (0xA750, "\u{a751}"), + (0xA752, "\u{a753}"), + (0xA754, "\u{a755}"), + (0xA756, "\u{a757}"), + (0xA758, "\u{a759}"), + (0xA75A, "\u{a75b}"), + (0xA75C, "\u{a75d}"), + (0xA75E, "\u{a75f}"), + (0xA760, "\u{a761}"), + (0xA762, "\u{a763}"), + (0xA764, "\u{a765}"), + (0xA766, "\u{a767}"), + (0xA768, "\u{a769}"), + (0xA76A, "\u{a76b}"), + (0xA76C, "\u{a76d}"), + (0xA76E, "\u{a76f}"), + (0xA779, "\u{a77a}"), + (0xA77B, "\u{a77c}"), + (0xA77D, "\u{1d79}"), + (0xA77E, "\u{a77f}"), + (0xA780, "\u{a781}"), + (0xA782, "\u{a783}"), + (0xA784, "\u{a785}"), + (0xA786, "\u{a787}"), + (0xA78B, "\u{a78c}"), + (0xA78D, "\u{265}"), + (0xA790, "\u{a791}"), + (0xA792, "\u{a793}"), + (0xA796, "\u{a797}"), + (0xA798, "\u{a799}"), + (0xA79A, "\u{a79b}"), + (0xA79C, "\u{a79d}"), + (0xA79E, "\u{a79f}"), + (0xA7A0, "\u{a7a1}"), + (0xA7A2, "\u{a7a3}"), + (0xA7A4, "\u{a7a5}"), + (0xA7A6, "\u{a7a7}"), + (0xA7A8, "\u{a7a9}"), + (0xA7AA, "\u{266}"), + (0xA7AB, "\u{25c}"), + (0xA7AC, "\u{261}"), + (0xA7AD, "\u{26c}"), + (0xA7AE, "\u{26a}"), + (0xA7B0, "\u{29e}"), + (0xA7B1, "\u{287}"), + (0xA7B2, "\u{29d}"), + (0xA7B3, "\u{ab53}"), + (0xA7B4, "\u{a7b5}"), + (0xA7B6, "\u{a7b7}"), + (0xA7B8, "\u{a7b9}"), + (0xA7BA, "\u{a7bb}"), + (0xA7BC, "\u{a7bd}"), + (0xA7BE, "\u{a7bf}"), + (0xA7C0, "\u{a7c1}"), + (0xA7C2, "\u{a7c3}"), + (0xA7C4, "\u{a794}"), + (0xA7C5, "\u{282}"), + (0xA7C6, "\u{1d8e}"), + (0xA7C7, "\u{a7c8}"), + (0xA7C9, "\u{a7ca}"), + (0xA7D0, "\u{a7d1}"), + (0xA7D6, "\u{a7d7}"), + (0xA7D8, "\u{a7d9}"), + (0xA7F5, "\u{a7f6}"), + (0xAB70, "\u{13a0}"), + (0xAB71, "\u{13a1}"), + (0xAB72, "\u{13a2}"), + (0xAB73, "\u{13a3}"), + (0xAB74, "\u{13a4}"), + (0xAB75, "\u{13a5}"), + (0xAB76, "\u{13a6}"), + (0xAB77, "\u{13a7}"), + (0xAB78, "\u{13a8}"), + (0xAB79, "\u{13a9}"), + (0xAB7A, "\u{13aa}"), + (0xAB7B, "\u{13ab}"), + (0xAB7C, "\u{13ac}"), + (0xAB7D, "\u{13ad}"), + (0xAB7E, "\u{13ae}"), + (0xAB7F, "\u{13af}"), + (0xAB80, "\u{13b0}"), + (0xAB81, "\u{13b1}"), + (0xAB82, "\u{13b2}"), + (0xAB83, "\u{13b3}"), + (0xAB84, "\u{13b4}"), + (0xAB85, "\u{13b5}"), + (0xAB86, "\u{13b6}"), + (0xAB87, "\u{13b7}"), + (0xAB88, "\u{13b8}"), + (0xAB89, "\u{13b9}"), + (0xAB8A, "\u{13ba}"), + (0xAB8B, "\u{13bb}"), + (0xAB8C, "\u{13bc}"), + (0xAB8D, "\u{13bd}"), + (0xAB8E, "\u{13be}"), + (0xAB8F, "\u{13bf}"), + (0xAB90, "\u{13c0}"), + (0xAB91, "\u{13c1}"), + (0xAB92, "\u{13c2}"), + (0xAB93, "\u{13c3}"), + (0xAB94, "\u{13c4}"), + (0xAB95, "\u{13c5}"), + (0xAB96, "\u{13c6}"), + (0xAB97, "\u{13c7}"), + (0xAB98, "\u{13c8}"), + (0xAB99, "\u{13c9}"), + (0xAB9A, "\u{13ca}"), + (0xAB9B, "\u{13cb}"), + (0xAB9C, "\u{13cc}"), + (0xAB9D, "\u{13cd}"), + (0xAB9E, "\u{13ce}"), + (0xAB9F, "\u{13cf}"), + (0xABA0, "\u{13d0}"), + (0xABA1, "\u{13d1}"), + (0xABA2, "\u{13d2}"), + (0xABA3, "\u{13d3}"), + (0xABA4, "\u{13d4}"), + (0xABA5, "\u{13d5}"), + (0xABA6, "\u{13d6}"), + (0xABA7, "\u{13d7}"), + (0xABA8, "\u{13d8}"), + (0xABA9, "\u{13d9}"), + (0xABAA, "\u{13da}"), + (0xABAB, "\u{13db}"), + (0xABAC, "\u{13dc}"), + (0xABAD, "\u{13dd}"), + (0xABAE, "\u{13de}"), + (0xABAF, "\u{13df}"), + (0xABB0, "\u{13e0}"), + (0xABB1, "\u{13e1}"), + (0xABB2, "\u{13e2}"), + (0xABB3, "\u{13e3}"), + (0xABB4, "\u{13e4}"), + (0xABB5, "\u{13e5}"), + (0xABB6, "\u{13e6}"), + (0xABB7, "\u{13e7}"), + (0xABB8, "\u{13e8}"), + (0xABB9, "\u{13e9}"), + (0xABBA, "\u{13ea}"), + (0xABBB, "\u{13eb}"), + (0xABBC, "\u{13ec}"), + (0xABBD, "\u{13ed}"), + (0xABBE, "\u{13ee}"), + (0xABBF, "\u{13ef}"), + (0xFB00, "\u{66}\u{66}"), + (0xFB01, "\u{66}\u{69}"), + (0xFB02, "\u{66}\u{6c}"), + (0xFB03, "\u{66}\u{66}\u{69}"), + (0xFB04, "\u{66}\u{66}\u{6c}"), + (0xFB05, "\u{73}\u{74}"), + (0xFB06, "\u{73}\u{74}"), + (0xFB13, "\u{574}\u{576}"), + (0xFB14, "\u{574}\u{565}"), + (0xFB15, "\u{574}\u{56b}"), + (0xFB16, "\u{57e}\u{576}"), + (0xFB17, "\u{574}\u{56d}"), + (0xFF21, "\u{ff41}"), + (0xFF22, "\u{ff42}"), + (0xFF23, "\u{ff43}"), + (0xFF24, "\u{ff44}"), + (0xFF25, "\u{ff45}"), + (0xFF26, "\u{ff46}"), + (0xFF27, "\u{ff47}"), + (0xFF28, "\u{ff48}"), + (0xFF29, "\u{ff49}"), + (0xFF2A, "\u{ff4a}"), + (0xFF2B, "\u{ff4b}"), + (0xFF2C, "\u{ff4c}"), + (0xFF2D, "\u{ff4d}"), + (0xFF2E, "\u{ff4e}"), + (0xFF2F, "\u{ff4f}"), + (0xFF30, "\u{ff50}"), + (0xFF31, "\u{ff51}"), + (0xFF32, "\u{ff52}"), + (0xFF33, "\u{ff53}"), + (0xFF34, "\u{ff54}"), + (0xFF35, "\u{ff55}"), + (0xFF36, "\u{ff56}"), + (0xFF37, "\u{ff57}"), + (0xFF38, "\u{ff58}"), + (0xFF39, "\u{ff59}"), + (0xFF3A, "\u{ff5a}"), + (0x10400, "\u{10428}"), + (0x10401, "\u{10429}"), + (0x10402, "\u{1042a}"), + (0x10403, "\u{1042b}"), + (0x10404, "\u{1042c}"), + (0x10405, "\u{1042d}"), + (0x10406, "\u{1042e}"), + (0x10407, "\u{1042f}"), + (0x10408, "\u{10430}"), + (0x10409, "\u{10431}"), + (0x1040A, "\u{10432}"), + (0x1040B, "\u{10433}"), + (0x1040C, "\u{10434}"), + (0x1040D, "\u{10435}"), + (0x1040E, "\u{10436}"), + (0x1040F, "\u{10437}"), + (0x10410, "\u{10438}"), + (0x10411, "\u{10439}"), + (0x10412, "\u{1043a}"), + (0x10413, "\u{1043b}"), + (0x10414, "\u{1043c}"), + (0x10415, "\u{1043d}"), + (0x10416, "\u{1043e}"), + (0x10417, "\u{1043f}"), + (0x10418, "\u{10440}"), + (0x10419, "\u{10441}"), + (0x1041A, "\u{10442}"), + (0x1041B, "\u{10443}"), + (0x1041C, "\u{10444}"), + (0x1041D, "\u{10445}"), + (0x1041E, "\u{10446}"), + (0x1041F, "\u{10447}"), + (0x10420, "\u{10448}"), + (0x10421, "\u{10449}"), + (0x10422, "\u{1044a}"), + (0x10423, "\u{1044b}"), + (0x10424, "\u{1044c}"), + (0x10425, "\u{1044d}"), + (0x10426, "\u{1044e}"), + (0x10427, "\u{1044f}"), + (0x104B0, "\u{104d8}"), + (0x104B1, "\u{104d9}"), + (0x104B2, "\u{104da}"), + (0x104B3, "\u{104db}"), + (0x104B4, "\u{104dc}"), + (0x104B5, "\u{104dd}"), + (0x104B6, "\u{104de}"), + (0x104B7, "\u{104df}"), + (0x104B8, "\u{104e0}"), + (0x104B9, "\u{104e1}"), + (0x104BA, "\u{104e2}"), + (0x104BB, "\u{104e3}"), + (0x104BC, "\u{104e4}"), + (0x104BD, "\u{104e5}"), + (0x104BE, "\u{104e6}"), + (0x104BF, "\u{104e7}"), + (0x104C0, "\u{104e8}"), + (0x104C1, "\u{104e9}"), + (0x104C2, "\u{104ea}"), + (0x104C3, "\u{104eb}"), + (0x104C4, "\u{104ec}"), + (0x104C5, "\u{104ed}"), + (0x104C6, "\u{104ee}"), + (0x104C7, "\u{104ef}"), + (0x104C8, "\u{104f0}"), + (0x104C9, "\u{104f1}"), + (0x104CA, "\u{104f2}"), + (0x104CB, "\u{104f3}"), + (0x104CC, "\u{104f4}"), + (0x104CD, "\u{104f5}"), + (0x104CE, "\u{104f6}"), + (0x104CF, "\u{104f7}"), + (0x104D0, "\u{104f8}"), + (0x104D1, "\u{104f9}"), + (0x104D2, "\u{104fa}"), + (0x104D3, "\u{104fb}"), + (0x10570, "\u{10597}"), + (0x10571, "\u{10598}"), + (0x10572, "\u{10599}"), + (0x10573, "\u{1059a}"), + (0x10574, "\u{1059b}"), + (0x10575, "\u{1059c}"), + (0x10576, "\u{1059d}"), + (0x10577, "\u{1059e}"), + (0x10578, "\u{1059f}"), + (0x10579, "\u{105a0}"), + (0x1057A, "\u{105a1}"), + (0x1057C, "\u{105a3}"), + (0x1057D, "\u{105a4}"), + (0x1057E, "\u{105a5}"), + (0x1057F, "\u{105a6}"), + (0x10580, "\u{105a7}"), + (0x10581, "\u{105a8}"), + (0x10582, "\u{105a9}"), + (0x10583, "\u{105aa}"), + (0x10584, "\u{105ab}"), + (0x10585, "\u{105ac}"), + (0x10586, "\u{105ad}"), + (0x10587, "\u{105ae}"), + (0x10588, "\u{105af}"), + (0x10589, "\u{105b0}"), + (0x1058A, "\u{105b1}"), + (0x1058C, "\u{105b3}"), + (0x1058D, "\u{105b4}"), + (0x1058E, "\u{105b5}"), + (0x1058F, "\u{105b6}"), + (0x10590, "\u{105b7}"), + (0x10591, "\u{105b8}"), + (0x10592, "\u{105b9}"), + (0x10594, "\u{105bb}"), + (0x10595, "\u{105bc}"), + (0x10C80, "\u{10cc0}"), + (0x10C81, "\u{10cc1}"), + (0x10C82, "\u{10cc2}"), + (0x10C83, "\u{10cc3}"), + (0x10C84, "\u{10cc4}"), + (0x10C85, "\u{10cc5}"), + (0x10C86, "\u{10cc6}"), + (0x10C87, "\u{10cc7}"), + (0x10C88, "\u{10cc8}"), + (0x10C89, "\u{10cc9}"), + (0x10C8A, "\u{10cca}"), + (0x10C8B, "\u{10ccb}"), + (0x10C8C, "\u{10ccc}"), + (0x10C8D, "\u{10ccd}"), + (0x10C8E, "\u{10cce}"), + (0x10C8F, "\u{10ccf}"), + (0x10C90, "\u{10cd0}"), + (0x10C91, "\u{10cd1}"), + (0x10C92, "\u{10cd2}"), + (0x10C93, "\u{10cd3}"), + (0x10C94, "\u{10cd4}"), + (0x10C95, "\u{10cd5}"), + (0x10C96, "\u{10cd6}"), + (0x10C97, "\u{10cd7}"), + (0x10C98, "\u{10cd8}"), + (0x10C99, "\u{10cd9}"), + (0x10C9A, "\u{10cda}"), + (0x10C9B, "\u{10cdb}"), + (0x10C9C, "\u{10cdc}"), + (0x10C9D, "\u{10cdd}"), + (0x10C9E, "\u{10cde}"), + (0x10C9F, "\u{10cdf}"), + (0x10CA0, "\u{10ce0}"), + (0x10CA1, "\u{10ce1}"), + (0x10CA2, "\u{10ce2}"), + (0x10CA3, "\u{10ce3}"), + (0x10CA4, "\u{10ce4}"), + (0x10CA5, "\u{10ce5}"), + (0x10CA6, "\u{10ce6}"), + (0x10CA7, "\u{10ce7}"), + (0x10CA8, "\u{10ce8}"), + (0x10CA9, "\u{10ce9}"), + (0x10CAA, "\u{10cea}"), + (0x10CAB, "\u{10ceb}"), + (0x10CAC, "\u{10cec}"), + (0x10CAD, "\u{10ced}"), + (0x10CAE, "\u{10cee}"), + (0x10CAF, "\u{10cef}"), + (0x10CB0, "\u{10cf0}"), + (0x10CB1, "\u{10cf1}"), + (0x10CB2, "\u{10cf2}"), + (0x118A0, "\u{118c0}"), + (0x118A1, "\u{118c1}"), + (0x118A2, "\u{118c2}"), + (0x118A3, "\u{118c3}"), + (0x118A4, "\u{118c4}"), + (0x118A5, "\u{118c5}"), + (0x118A6, "\u{118c6}"), + (0x118A7, "\u{118c7}"), + (0x118A8, "\u{118c8}"), + (0x118A9, "\u{118c9}"), + (0x118AA, "\u{118ca}"), + (0x118AB, "\u{118cb}"), + (0x118AC, "\u{118cc}"), + (0x118AD, "\u{118cd}"), + (0x118AE, "\u{118ce}"), + (0x118AF, "\u{118cf}"), + (0x118B0, "\u{118d0}"), + (0x118B1, "\u{118d1}"), + (0x118B2, "\u{118d2}"), + (0x118B3, "\u{118d3}"), + (0x118B4, "\u{118d4}"), + (0x118B5, "\u{118d5}"), + (0x118B6, "\u{118d6}"), + (0x118B7, "\u{118d7}"), + (0x118B8, "\u{118d8}"), + (0x118B9, "\u{118d9}"), + (0x118BA, "\u{118da}"), + (0x118BB, "\u{118db}"), + (0x118BC, "\u{118dc}"), + (0x118BD, "\u{118dd}"), + (0x118BE, "\u{118de}"), + (0x118BF, "\u{118df}"), + (0x16E40, "\u{16e60}"), + (0x16E41, "\u{16e61}"), + (0x16E42, "\u{16e62}"), + (0x16E43, "\u{16e63}"), + (0x16E44, "\u{16e64}"), + (0x16E45, "\u{16e65}"), + (0x16E46, "\u{16e66}"), + (0x16E47, "\u{16e67}"), + (0x16E48, "\u{16e68}"), + (0x16E49, "\u{16e69}"), + (0x16E4A, "\u{16e6a}"), + (0x16E4B, "\u{16e6b}"), + (0x16E4C, "\u{16e6c}"), + (0x16E4D, "\u{16e6d}"), + (0x16E4E, "\u{16e6e}"), + (0x16E4F, "\u{16e6f}"), + (0x16E50, "\u{16e70}"), + (0x16E51, "\u{16e71}"), + (0x16E52, "\u{16e72}"), + (0x16E53, "\u{16e73}"), + (0x16E54, "\u{16e74}"), + (0x16E55, "\u{16e75}"), + (0x16E56, "\u{16e76}"), + (0x16E57, "\u{16e77}"), + (0x16E58, "\u{16e78}"), + (0x16E59, "\u{16e79}"), + (0x16E5A, "\u{16e7a}"), + (0x16E5B, "\u{16e7b}"), + (0x16E5C, "\u{16e7c}"), + (0x16E5D, "\u{16e7d}"), + (0x16E5E, "\u{16e7e}"), + (0x16E5F, "\u{16e7f}"), + (0x1E900, "\u{1e922}"), + (0x1E901, "\u{1e923}"), + (0x1E902, "\u{1e924}"), + (0x1E903, "\u{1e925}"), + (0x1E904, "\u{1e926}"), + (0x1E905, "\u{1e927}"), + (0x1E906, "\u{1e928}"), + (0x1E907, "\u{1e929}"), + (0x1E908, "\u{1e92a}"), + (0x1E909, "\u{1e92b}"), + (0x1E90A, "\u{1e92c}"), + (0x1E90B, "\u{1e92d}"), + (0x1E90C, "\u{1e92e}"), + (0x1E90D, "\u{1e92f}"), + (0x1E90E, "\u{1e930}"), + (0x1E90F, "\u{1e931}"), + (0x1E910, "\u{1e932}"), + (0x1E911, "\u{1e933}"), + (0x1E912, "\u{1e934}"), + (0x1E913, "\u{1e935}"), + (0x1E914, "\u{1e936}"), + (0x1E915, "\u{1e937}"), + (0x1E916, "\u{1e938}"), + (0x1E917, "\u{1e939}"), + (0x1E918, "\u{1e93a}"), + (0x1E919, "\u{1e93b}"), + (0x1E91A, "\u{1e93c}"), + (0x1E91B, "\u{1e93d}"), + (0x1E91C, "\u{1e93e}"), + (0x1E91D, "\u{1e93f}"), + (0x1E91E, "\u{1e940}"), + (0x1E91F, "\u{1e941}"), + (0x1E920, "\u{1e942}"), + (0x1E921, "\u{1e943}"), +]; + +/// Fold a string under Unicode full case folding, matching the service's +/// resolution of condition key names and tag keys. ASCII letters fold to +/// ASCII lowercase; a non-ASCII code point folds per [`CASE_FOLD`] or to +/// itself. +#[must_use] +pub fn case_fold(s: &str) -> String { + // Fast path: an all-ASCII key (the overwhelmingly common case) folds + // to its ASCII lowercase without touching the table, since every ASCII + // letter's fold is its ASCII lowercase. + if s.is_ascii() { + return s.to_ascii_lowercase(); + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + let cp = c as u32; + match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { + Ok(i) => out.push_str(CASE_FOLD[i].1), + Err(_) => out.push(c), + } + } + out +} + +/// Whether two strings are equal under Unicode full case folding. +#[must_use] +pub fn eq_fold(a: &str, b: &str) -> bool { + case_fold(a) == case_fold(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_sorted_and_unique() { + for w in CASE_FOLD.windows(2) { + assert!( + w[0].0 < w[1].0, + "table must be sorted and unique by code point" + ); + } + } + + #[test] + fn spot_checks() { + // Single-character folds. + assert_eq!(case_fold("\u{017f}"), "s"); // long s + assert_eq!(case_fold("\u{212a}"), "k"); // Kelvin sign + assert_eq!(case_fold("A"), "a"); + // Multi-character expansions. + assert_eq!(case_fold("\u{00df}"), "ss"); // sharp s + assert_eq!(case_fold("\u{1e9e}"), "ss"); // capital sharp s + assert_eq!(case_fold("\u{fb05}"), "st"); // st ligature + // Greek final sigma folds to the same as sigma. + assert_eq!(case_fold("\u{03c2}"), case_fold("\u{03c3}")); + assert_eq!(case_fold("\u{03a3}"), case_fold("\u{03c3}")); + // Cherokee folds to another Cherokee code point (a non-ASCII target). + assert_eq!(case_fold("\u{13f8}"), "\u{13f0}"); + // Code points that must be absent from the table (fold to self). + assert_eq!(case_fold("\u{0131}"), "\u{0131}"); // dotless i + // Dotted I folds to 'i' plus a combining dot, not plain 'i'. + assert_eq!(case_fold("\u{0130}"), "i\u{0307}"); + } + + #[test] + fn ascii_fast_path_matches_table() { + // The ASCII fast path must agree with a per-code-point table fold + // on every ASCII string. + for s in [ + "", + "AWS:SecureTransport", + "dynamodb:LeadingKeys", + "aws:PrincipalTag/Team", + "MiXeD123_.-/", + "abc", + ] { + let table: String = s + .chars() + .map(|c| { + let cp = c as u32; + match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) { + Ok(i) => CASE_FOLD[i].1.to_owned(), + Err(_) => c.to_string(), + } + }) + .collect(); + assert_eq!(case_fold(s), table, "fast path diverged on {s:?}"); + assert_eq!(case_fold(s), s.to_ascii_lowercase()); + } + } +} diff --git a/crates/auth/src/policy/condition.rs b/crates/auth/src/policy/condition.rs index b2ef1297f..a48f60840 100644 --- a/crates/auth/src/policy/condition.rs +++ b/crates/auth/src/policy/condition.rs @@ -109,7 +109,15 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext Some(vals) => evaluate_single_value_condition(inner, &vals, &expanded_values), }, other => match context_values { - None => false, + // A negated operator on an absent single-valued key evaluates true + // in AWS IAM: the key has no value, so it cannot equal the policy + // value, so "not equals" holds. This makes a Deny gated on + // StringNotEquals apply when the caller omits the key (the + // confused-deputy guard). Measured on live IAM/STS and DynamoDB. + // Bare operators on multivalued keys + // never match, present or absent (BR-7085), so the rule is scoped + // to single-valued keys. + None => is_negative_operator(other) && !is_multivalued, // Bare operator on a multivalued key never matches (BR-7085). This is // fail-safe: a bare Deny becomes a no-op (matching AWS, which forces the // author to use ForAnyValue:), and a bare Allow allowlist stops granting. @@ -119,6 +127,74 @@ pub fn evaluate_condition(condition: &Condition, context: &impl ConditionContext } } +/// Three-valued outcome of evaluating a statement's condition block. +/// +/// A condition key falls into one of three cases, and each is treated +/// differently: +/// 1. The context resolves the key to a value: evaluate normally. +/// 2. The context resolves the key as absent in the same way the service would +/// have it absent: IAM absent-key semantics apply (including the rule that a +/// negated operator on an absent single-valued key matches). +/// 3. The service would populate the key but this context cannot resolve it +/// (`is_unresolvable_key`): the statement fails closed whatever the operator. +/// A `Deny` that depends on it blocks; an `Allow` that depends on it does not +/// grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConditionMatch { + /// All conditions are satisfied; the statement applies. + Match, + /// A condition is not satisfied; the statement does not apply. + NoMatch, + /// A condition depends on a key the service populates but this context + /// cannot resolve. The statement fails closed. + Unresolvable, +} + +/// Evaluate one condition three-valued. +/// +/// Returns [`ConditionMatch::Unresolvable`] when the key is unresolved and the +/// context reports it as service-populated (case 3). Otherwise returns `Match` +/// or `NoMatch` from the ordinary evaluation, so cases 1 and 2 are unchanged. +pub fn evaluate_condition_match( + condition: &Condition, + context: &impl ConditionContext, +) -> ConditionMatch { + if context.resolve_key(&condition.key).is_none() && context.is_unresolvable_key(&condition.key) + { + return ConditionMatch::Unresolvable; + } + if evaluate_condition(condition, context) { + ConditionMatch::Match + } else { + ConditionMatch::NoMatch + } +} + +/// Evaluate a statement's whole condition block (all conditions are ANDed). +/// +/// `NoMatch` dominates: if any condition is definitely not satisfied the +/// statement does not apply, even if another condition is unresolvable. Failing +/// that, any unresolvable condition makes the block `Unresolvable` (fail +/// closed). Otherwise every condition matched. +pub fn evaluate_statement_conditions( + conditions: &[Condition], + context: &impl ConditionContext, +) -> ConditionMatch { + let mut any_unresolvable = false; + for condition in conditions { + match evaluate_condition_match(condition, context) { + ConditionMatch::Match => {} + ConditionMatch::NoMatch => return ConditionMatch::NoMatch, + ConditionMatch::Unresolvable => any_unresolvable = true, + } + } + if any_unresolvable { + ConditionMatch::Unresolvable + } else { + ConditionMatch::Match + } +} + /// Expand IAM policy variables in a string value. /// /// Replaces `${variable}` patterns with their resolved values from the context. @@ -208,11 +284,13 @@ fn is_negative_operator(op: &ConditionOperator) -> bool { matches!( op, ConditionOperator::StringNotEquals + | ConditionOperator::StringNotEqualsIgnoreCase | ConditionOperator::NumericNotEquals | ConditionOperator::DateNotEquals | ConditionOperator::StringNotLike | ConditionOperator::ArnNotEquals | ConditionOperator::ArnNotLike + | ConditionOperator::NotIpAddress ) } @@ -231,6 +309,9 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::StringEqualsIgnoreCase => { context_value.eq_ignore_ascii_case(policy_value) } + ConditionOperator::StringNotEqualsIgnoreCase => { + !context_value.eq_ignore_ascii_case(policy_value) + } ConditionOperator::StringLike => wildcard_match(policy_value, context_value), ConditionOperator::StringNotLike => !wildcard_match(policy_value, context_value), ConditionOperator::NumericEquals => parse_f64_cmp(context_value, policy_value, f64::eq), @@ -268,6 +349,17 @@ fn compare_single(op: &ConditionOperator, context_value: &str, policy_value: &st ConditionOperator::ArnNotEquals => context_value != policy_value, ConditionOperator::ArnLike => super::matcher::arn_match(policy_value, context_value), ConditionOperator::ArnNotLike => !super::matcher::arn_match(policy_value, context_value), + // IpAddress matches only when the caller address and the policy range + // are the same family and the address is in the range. NotIpAddress + // matches only when they are the same family and the address is not in + // the range. A family mismatch or a malformed value makes both false, + // matching the live service. + ConditionOperator::IpAddress => { + super::matcher::ip_in_cidr(policy_value, context_value) == Some(true) + } + ConditionOperator::NotIpAddress => { + super::matcher::ip_in_cidr(policy_value, context_value) == Some(false) + } // Null, ForAllValues, ForAnyValue, IfExists handled by caller _ => false, } diff --git a/crates/auth/src/policy/condition/tests.rs b/crates/auth/src/policy/condition/tests.rs index abb870568..8f0d3b3d6 100644 --- a/crates/auth/src/policy/condition/tests.rs +++ b/crates/auth/src/policy/condition/tests.rs @@ -8,25 +8,37 @@ use crate::policy::context::ConditionContext; use std::collections::HashMap; /// Simple test context that maps keys to values. -struct TestContext(HashMap>); +struct TestContext { + values: HashMap>, + unresolvable: std::collections::HashSet, +} impl TestContext { fn new() -> Self { - Self(HashMap::new()) + Self { + values: HashMap::new(), + unresolvable: std::collections::HashSet::new(), + } } fn with(mut self, key: &str, values: Vec<&str>) -> Self { - self.0.insert( + self.values.insert( key.to_owned(), values.into_iter().map(ToOwned::to_owned).collect(), ); self } + + /// Mark `key` as service-populated but unresolvable (case 3). + fn unresolvable(mut self, key: &str) -> Self { + self.unresolvable.insert(key.to_owned()); + self + } } impl ConditionContext for TestContext { fn resolve_key(&self, key: &str) -> Option> { - self.0 + self.values .get(key) .map(|v| v.iter().map(|s| s.as_str()).collect()) } @@ -36,6 +48,10 @@ impl ConditionContext for TestContext { // production arity rule. matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") } + + fn is_unresolvable_key(&self, key: &str) -> bool { + self.unresolvable.contains(key) + } } fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition { @@ -48,3 +64,4 @@ fn cond(op: ConditionOperator, key: &str, values: Vec<&str>) -> Condition { mod multivalued; mod operators; +mod three_case; diff --git a/crates/auth/src/policy/condition/tests/multivalued.rs b/crates/auth/src/policy/condition/tests/multivalued.rs index 9cc034a16..cf892a096 100644 --- a/crates/auth/src/policy/condition/tests/multivalued.rs +++ b/crates/auth/src/policy/condition/tests/multivalued.rs @@ -373,3 +373,19 @@ fn for_any_value_string_not_equals_one_outside_set() { &ctx )); } + +#[test] +fn bare_negative_op_multivalued_absent_no_match() { + // BR-7085 preserved: a bare negated operator on an ABSENT multivalued key + // still never matches. The negated-absent rule is scoped to single-valued + // keys; multivalued keys require ForAllValues/ForAnyValue. + let ctx = TestContext::new(); + assert!(!evaluate_condition( + &cond( + ConditionOperator::StringNotEquals, + "dynamodb:Attributes", + vec!["ssn"] + ), + &ctx + )); +} diff --git a/crates/auth/src/policy/condition/tests/operators.rs b/crates/auth/src/policy/condition/tests/operators.rs index 1c0fd14bd..4a7fded91 100644 --- a/crates/auth/src/policy/condition/tests/operators.rs +++ b/crates/auth/src/policy/condition/tests/operators.rs @@ -367,3 +367,69 @@ fn date_less_than() { &ctx )); } + +// --- Negated operators on an absent single-valued key --- +// +// AWS IAM: an absent key cannot equal the policy value, so a negated operator +// ("not equals") holds. A Deny gated on StringNotEquals therefore applies when +// the caller omits the key. Measured on live IAM/STS and DynamoDB. + +#[test] +fn string_not_equals_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond( + ConditionOperator::StringNotEquals, + "sts:ExternalId", + vec!["secret"] + ), + &ctx + )); +} + +#[test] +fn string_not_like_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond(ConditionOperator::StringNotLike, "k", vec!["sec*"]), + &ctx + )); +} + +#[test] +fn arn_not_equals_absent_key_matches() { + let ctx = TestContext::new(); + assert!(evaluate_condition( + &cond( + ConditionOperator::ArnNotEquals, + "k", + vec!["arn:aws:iam::123456789012:user/x"] + ), + &ctx + )); +} + +#[test] +fn positive_op_absent_key_still_no_match() { + // Regression: positive operators on an absent key remain false. + let ctx = TestContext::new(); + assert!(!evaluate_condition( + &cond(ConditionOperator::StringEquals, "k", vec!["x"]), + &ctx + )); +} + +#[test] +fn string_not_equals_present_key_still_compares() { + // Regression: present key still evaluates normally. + let ctx = TestContext::new().with("k", vec!["secret"]); + assert!(!evaluate_condition( + &cond(ConditionOperator::StringNotEquals, "k", vec!["secret"]), + &ctx + )); + let ctx2 = TestContext::new().with("k", vec!["other"]); + assert!(evaluate_condition( + &cond(ConditionOperator::StringNotEquals, "k", vec!["secret"]), + &ctx2 + )); +} diff --git a/crates/auth/src/policy/condition/tests/three_case.rs b/crates/auth/src/policy/condition/tests/three_case.rs new file mode 100644 index 000000000..f0aabd82f --- /dev/null +++ b/crates/auth/src/policy/condition/tests/three_case.rs @@ -0,0 +1,151 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the three-case condition rule: a key the context resolves (case +//! 1), a key genuinely absent (case 2), and a key the service populates but the +//! context cannot resolve (case 3, fail closed). + +use super::*; +use crate::policy::condition::{ + ConditionMatch, evaluate_condition_match, evaluate_statement_conditions, +}; + +#[test] +fn present_key_matches_or_not() { + let ctx = TestContext::new().with("aws:PrincipalArn", vec!["arn:a"]); + let eq = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:a"], + ); + assert_eq!(evaluate_condition_match(&eq, &ctx), ConditionMatch::Match); + let ne = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:b"], + ); + assert_eq!(evaluate_condition_match(&ne, &ctx), ConditionMatch::NoMatch); +} + +#[test] +fn absent_key_keeps_iam_semantics() { + // Case 2: a genuinely absent single-valued key. A positive operator does + // not match; a negated operator matches (the negated-absent rule). + let ctx = TestContext::new(); + let pos = cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["x"], + ); + assert_eq!( + evaluate_condition_match(&pos, &ctx), + ConditionMatch::NoMatch + ); + let neg = cond( + ConditionOperator::StringNotEquals, + "aws:PrincipalTag/team", + vec!["x"], + ); + assert_eq!(evaluate_condition_match(&neg, &ctx), ConditionMatch::Match); +} + +#[test] +fn unresolvable_key_is_unresolvable_for_every_operator() { + // Case 3: the context reports the key as service-populated but unresolved. + // Every operator yields Unresolvable, including a negated one (so the + // negated-absent rule cannot fire) and Null. + let ctx = TestContext::new().unresolvable("aws:PrincipalOrgID"); + for op in [ + ConditionOperator::StringEquals, + ConditionOperator::StringNotEquals, + ConditionOperator::Null, + ] { + let c = cond(op, "aws:PrincipalOrgID", vec!["o-1"]); + assert_eq!( + evaluate_condition_match(&c, &ctx), + ConditionMatch::Unresolvable + ); + } +} + +#[test] +fn resolved_unresolvable_key_uses_its_value() { + // If the context both lists a key as unresolvable and resolves it, the + // resolved value wins (the guard only fires when resolve_key is None). + let ctx = TestContext::new() + .with("aws:PrincipalArn", vec!["arn:a"]) + .unresolvable("aws:PrincipalArn"); + let c = cond( + ConditionOperator::StringEquals, + "aws:PrincipalArn", + vec!["arn:a"], + ); + assert_eq!(evaluate_condition_match(&c, &ctx), ConditionMatch::Match); +} + +#[test] +fn statement_nomatch_dominates_unresolvable() { + // AND of conditions: a definite NoMatch means the statement does not apply, + // even if another condition is unresolvable. + let ctx = TestContext::new() + .with("aws:PrincipalTag/team", vec!["employees"]) + .unresolvable("aws:PrincipalOrgID"); + let conds = vec![ + cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + ), + cond( + ConditionOperator::StringEquals, + "aws:PrincipalOrgID", + vec!["o-1"], + ), + ]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::NoMatch + ); +} + +#[test] +fn statement_unresolvable_when_only_unresolvable_present() { + let ctx = TestContext::new() + .with("aws:PrincipalTag/team", vec!["contractors"]) + .unresolvable("aws:PrincipalOrgID"); + let conds = vec![ + cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + ), + cond( + ConditionOperator::StringEquals, + "aws:PrincipalOrgID", + vec!["o-1"], + ), + ]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::Unresolvable + ); +} + +#[test] +fn statement_all_match() { + let ctx = TestContext::new().with("aws:PrincipalTag/team", vec!["contractors"]); + let conds = vec![cond( + ConditionOperator::StringEquals, + "aws:PrincipalTag/team", + vec!["contractors"], + )]; + assert_eq!( + evaluate_statement_conditions(&conds, &ctx), + ConditionMatch::Match + ); + // An empty condition block always matches. + assert_eq!( + evaluate_statement_conditions(&[], &ctx), + ConditionMatch::Match + ); +} diff --git a/crates/auth/src/policy/context.rs b/crates/auth/src/policy/context.rs index 36384b8cb..ee8a9ff7f 100755 --- a/crates/auth/src/policy/context.rs +++ b/crates/auth/src/policy/context.rs @@ -30,6 +30,112 @@ pub trait ConditionContext { /// present. Verified against real AWS IAM (BR-7085). Returning `false` for an /// unknown key preserves the normal single-valued evaluation path. fn is_multivalued_key(&self, key: &str) -> bool; + + /// Whether `key` is one the service populates on a request of this kind but + /// which this context cannot resolve. + /// + /// This distinguishes a key that is genuinely absent (the service would + /// also have it absent, so IAM absent-key semantics apply) from a key the + /// service populates that ExtendDB has no value for. A statement gated on + /// an unresolvable key fails closed: a `Deny` blocks, an `Allow` does not + /// grant. The default is `false` (no unresolvable keys), so a context that + /// resolves every key it is asked about needs no override. + fn is_unresolvable_key(&self, _key: &str) -> bool { + false + } +} + +/// Look up a tag map by key, matching the live service. +/// +/// Resolve a tag condition key against a stored tag map, deterministically. +/// +/// The live service stores two tag keys that differ by more than ASCII case +/// (for example `mass` and `ma\u{00df}`) as distinct keys, and resolves a policy +/// key to the exact stored key when one matches exactly. ExtendDB mirrors that: +/// a byte-exact stored key wins; otherwise the key is resolved under `norm` +/// (Unicode case folding on the identity path, ASCII case on the trust path), +/// and when several stored keys normalize to the same string the smallest by +/// code point is chosen, so resolution is deterministic rather than depending on +/// `HashMap` iteration order. +/// +/// `sub_key` is the original (un-normalized) tag sub-key from the policy. +fn tag_lookup<'a>( + map: &'a HashMap, + sub_key: &str, + norm: impl Fn(&str) -> String, +) -> Option> { + if let Some(v) = map.get(sub_key) { + return Some(vec![v.as_str()]); + } + let want = norm(sub_key); + map.iter() + .filter(|(k, _)| norm(k) == want) + .min_by(|a, b| a.0.cmp(b.0)) + .map(|(_, v)| vec![v.as_str()]) +} + +/// Strip a tag prefix (`aws:PrincipalTag/`, `aws:RequestTag/`, +/// `aws:ResourceTag/`, `dynamodb:ResourceTag/`) from the front of `key` +/// ASCII-case-insensitively, returning the original (un-normalized) tag sub-key. +/// +/// The prefix is ASCII, so the service (and ExtendDB) match it without regard to +/// ASCII case; the returned sub-key keeps its original bytes so `tag_lookup` can +/// prefer a byte-exact stored key. +fn strip_prefix_ci<'a>(key: &'a str, prefix: &str) -> Option<&'a str> { + let head = key.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + key.get(prefix.len()..) + } else { + None + } +} + +/// Documented AWS global condition keys the live service populates for a +/// request from one of ExtendDB's callers but that this build cannot resolve to +/// the value the service would have. A statement using one fails closed: a +/// `Deny` blocks and an `Allow` does not grant, instead of the key being +/// treated as absent (where a negated operator on an absent single-valued key +/// would spuriously match). +/// +/// This set is empty. ExtendDB resolves every global key the live service +/// populates for a signed IAM-user or assumed-role request, or an `AssumeRole` +/// call: the principal identity keys, `aws:userid`, the request time, region, +/// TLS state, source address, user agent, principal and resource account, and +/// the tag keys. Every other `aws:` global key is one the service leaves absent +/// for a standalone AWS account reached by a direct caller (the organization, +/// VPC-endpoint, federation, MFA, service-principal and cross-service keys), or +/// one the service does not recognize; the service treats both as absent. +/// ExtendDB does the same by not listing them here, so an unresolved key keeps +/// IAM absent-key semantics. The slice stays so a future global key the service +/// begins populating before ExtendDB resolves it can be made to fail closed by +/// listing it. +const FAIL_CLOSED_GLOBAL_KEYS: &[&str] = &[]; + +/// Whether an unresolved condition key must fail closed for the context that +/// could not resolve it. Consulted only after `resolve_key` returned `None`. +/// +/// A key fails closed only when it is on [`FAIL_CLOSED_GLOBAL_KEYS`] (currently +/// empty). Every other unresolved key, `aws:` global, `dynamodb:`, +/// `sts:` or a tag sub-key, keeps absent-key semantics: a genuinely absent key +/// does not match, and a negated operator on an absent single-valued key +/// matches, which is what the live service does for a key it does not populate. +#[must_use] +pub fn aws_key_is_unresolvable(key: &str) -> bool { + let folded = super::casefold::case_fold(key); + FAIL_CLOSED_GLOBAL_KEYS + .iter() + .any(|k| super::casefold::case_fold(k) == folded) +} + +/// The request time as the pair (`aws:CurrentTime` as RFC 3339 UTC, +/// `aws:EpochTime` as whole seconds since the epoch). +#[must_use] +pub fn request_time_strings() -> (String, String) { + let now = time::OffsetDateTime::now_utc(); + let iso = now + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(); + (iso, now.unix_timestamp().to_string()) } /// Request parameters extracted from a `DynamoDB` operation for condition evaluation. @@ -49,6 +155,44 @@ pub struct RequestParams { pub return_consumed_capacity: Option, /// The enclosing operation for batch/transact sub-operations. pub enclosing_operation: Option, + /// The caller ARN (`aws:PrincipalArn`), when known. + pub principal_arn: Option, + /// The caller's account id (`aws:PrincipalAccount`), when known. + pub principal_account: Option, + /// The server region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the request arrived over TLS (`aws:SecureTransport`). The + /// listener is entirely TLS or entirely plaintext, so this is a + /// server-level value, not per-connection. + pub secure_transport: bool, + /// The caller principal type (`aws:PrincipalType`): `User` for an IAM user, + /// `AssumedRole` for a role session. + pub principal_type: Option, + /// The IAM user name (`aws:username`) for a user caller; `None` for a role + /// session, where the service does not populate it. + pub username: Option, + /// The caller's source IP (`aws:SourceIp`): the peer address of the + /// connection the request arrived on. `None` when it could not be + /// determined, where it keeps absent-key semantics. + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`), when present. + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`): an `AIDA...` id for a + /// user, `:` for a role session. + pub userid: Option, + /// The account that owns the target resource (`aws:ResourceAccount`). For + /// ExtendDB this is the account in the resource ARN, which is the caller's + /// account (ExtendDB resources are same-account). + pub resource_account: Option, + /// The session issue time (`aws:TokenIssueTime`), RFC 3339 UTC, set only for + /// a role-session caller. `None` for an IAM user, where the service leaves + /// the key absent. + pub token_issue_time: Option, + /// Whether the caller authenticated with MFA (`aws:MultiFactorAuthPresent`). + /// The service populates this for a role session (`false` for a session + /// assumed without MFA) and leaves it absent for an IAM user with long-term + /// keys. `Some(false)` for a role session, `None` for an IAM user. + pub multi_factor_auth_present: Option, } /// Context for evaluating conditions on `DynamoDB` operations. @@ -75,6 +219,38 @@ pub struct RequestContext { pub full_table_scan: Option, /// The enclosing operation for batch/transact sub-operations. pub enclosing_operation: Option, + /// The caller ARN (`aws:PrincipalArn`), when known. + pub principal_arn: Option, + /// The caller's account id (`aws:PrincipalAccount`), when known. + pub principal_account: Option, + /// The server region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the request arrived over TLS (`aws:SecureTransport`). + pub secure_transport: bool, + /// The caller principal type (`aws:PrincipalType`). + pub principal_type: Option, + /// The IAM user name (`aws:username`) for a user caller; `None` for a role + /// session. + pub username: Option, + /// The caller's source IP (`aws:SourceIp`). + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`). + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`). + pub userid: Option, + /// The target resource owner account (`aws:ResourceAccount`). + pub resource_account: Option, + /// The session issue time (`aws:TokenIssueTime`) for a role-session caller; + /// `None` for an IAM user (absent key). + pub token_issue_time: Option, + /// Whether the caller authenticated with MFA (`aws:MultiFactorAuthPresent`): + /// `Some(false)` for a role session assumed without MFA, `None` for an IAM + /// user (absent key), matching the service. + pub multi_factor_auth_present: Option, + /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). + pub current_time: String, + /// The request time formatted for `aws:EpochTime` (whole seconds). + pub epoch_time: String, } impl RequestContext { @@ -90,6 +266,7 @@ impl RequestContext { is_scan: bool, params: RequestParams, ) -> Self { + let (current_time, epoch_time) = request_time_strings(); Self { principal_tags, resource_tags, @@ -100,37 +277,88 @@ impl RequestContext { return_consumed_capacity: params.return_consumed_capacity, full_table_scan: if is_scan { Some(true) } else { None }, enclosing_operation: params.enclosing_operation, + principal_arn: params.principal_arn, + principal_account: params.principal_account, + region: params.region, + secure_transport: params.secure_transport, + principal_type: params.principal_type, + username: params.username, + source_ip: params.source_ip, + user_agent: params.user_agent, + userid: params.userid, + resource_account: params.resource_account, + token_issue_time: params.token_issue_time, + multi_factor_auth_present: params.multi_factor_auth_present, + current_time, + epoch_time, } } } impl ConditionContext for RequestContext { fn resolve_key(&self, key: &str) -> Option> { - if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { - self.principal_tags.get(tag_key).map(|v| vec![v.as_str()]) - } else if let Some(tag_key) = key.strip_prefix("dynamodb:ResourceTag/") { - self.resource_tags.get(tag_key).map(|v| vec![v.as_str()]) + // Condition key names are resolved under Unicode full case folding, + // Tag keys are resolved deterministically: a byte-exact stored key + // wins, else the smallest fold-matching stored key. The tag prefix is + // ASCII and matched case-insensitively. Non-tag key names are matched + // under Unicode full case folding, matching the live DynamoDB path. + if let Some(sub) = strip_prefix_ci(key, "aws:PrincipalTag/") { + tag_lookup(&self.principal_tags, sub, super::casefold::case_fold) + } else if let Some(sub) = strip_prefix_ci(key, "aws:ResourceTag/") { + // The service evaluates resource tags as aws:ResourceTag/*. ExtendDB + // resolves them from the same resource-tag map it uses for + // dynamodb:ResourceTag/*. + tag_lookup(&self.resource_tags, sub, super::casefold::case_fold) + } else if let Some(sub) = strip_prefix_ci(key, "dynamodb:ResourceTag/") { + tag_lookup(&self.resource_tags, sub, super::casefold::case_fold) } else { - match key { - "dynamodb:LeadingKeys" => self + match super::casefold::case_fold(key).as_str() { + "dynamodb:leadingkeys" => self .leading_keys .as_ref() .map(|v| v.iter().map(std::string::String::as_str).collect()), - "dynamodb:Attributes" => self + "dynamodb:attributes" => self .attributes .as_ref() .map(|v| v.iter().map(std::string::String::as_str).collect()), - "dynamodb:Select" => self.select.as_deref().map(|v| vec![v]), - "dynamodb:ReturnValues" => self.return_values.as_deref().map(|v| vec![v]), - "dynamodb:ReturnConsumedCapacity" => { + "dynamodb:select" => self.select.as_deref().map(|v| vec![v]), + "dynamodb:returnvalues" => self.return_values.as_deref().map(|v| vec![v]), + "dynamodb:returnconsumedcapacity" => { self.return_consumed_capacity.as_deref().map(|v| vec![v]) } - "dynamodb:FullTableScan" => self + "dynamodb:fulltablescan" => self .full_table_scan .map(|v| vec![if v { "true" } else { "false" }]), - "dynamodb:EnclosingOperation" => { + "dynamodb:enclosingoperation" => { self.enclosing_operation.as_deref().map(|v| vec![v]) } + "aws:principalarn" => self.principal_arn.as_deref().map(|v| vec![v]), + "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:requestedregion" => self.region.as_deref().map(|v| vec![v]), + "aws:securetransport" => Some(vec![if self.secure_transport { + "true" + } else { + "false" + }]), + "aws:currenttime" => Some(vec![self.current_time.as_str()]), + "aws:epochtime" => Some(vec![self.epoch_time.as_str()]), + "aws:principaltype" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:username" => self.username.as_deref().map(|v| vec![v]), + "aws:sourceip" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:useragent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:userid" => self.userid.as_deref().map(|v| vec![v]), + "aws:resourceaccount" => self.resource_account.as_deref().map(|v| vec![v]), + "aws:tokenissuetime" => self.token_issue_time.as_deref().map(|v| vec![v]), + // Populated for a role session (false for a session assumed + // without MFA), absent for an IAM user, matching the service. + "aws:multifactorauthpresent" => self + .multi_factor_auth_present + .map(|v| vec![if v { "true" } else { "false" }]), + // ExtendDB is not an AWS service calling on a principal's behalf, + // so these are always false, matching the service for a direct + // caller. + "aws:viaawsservice" => Some(vec!["false"]), + "aws:principalisawsservice" => Some(vec!["false"]), _ => None, } } @@ -139,38 +367,145 @@ impl ConditionContext for RequestContext { fn is_multivalued_key(&self, key: &str) -> bool { // Per the AWS DynamoDB service authorization reference these keys are // `ArrayOfString` (multivalued); all other DynamoDB keys and tag keys are - // single-valued. Verified against real AWS IAM (BR-7085). - matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") + // single-valued. Verified against real AWS IAM (BR-7085). Key names are + // matched under Unicode full case folding, matching the service. + let folded = super::casefold::case_fold(key); + matches!( + folded.as_str(), + "dynamodb:leadingkeys" | "dynamodb:attributes" + ) + } + + fn is_unresolvable_key(&self, key: &str) -> bool { + // An unresolved aws: global key that is neither a tag sub-key nor on the + // absent-ok allowlist is one the service populates but this context has + // no value for (for example aws:PrincipalOrgID, aws:SourceIp): it fails + // closed. Keys resolved above never reach here. + aws_key_is_unresolvable(key) } } /// Context for evaluating trust policy conditions during `AssumeRole`. /// -/// Trust policies can reference `aws:PrincipalTag/*` and `sts:ExternalId`. -/// DynamoDB-specific keys are not applicable. +/// Trust policies can reference `aws:PrincipalTag/*`, `sts:ExternalId`, the +/// caller identity keys `aws:PrincipalArn` / `aws:PrincipalAccount` / +/// `aws:PrincipalType` / `aws:username`, the session-tag keys +/// `aws:RequestTag/*` / `aws:TagKeys`, and the request-wide keys +/// `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, +/// `aws:EpochTime`, `aws:ViaAWSService` and `aws:PrincipalIsAWSService`, all of +/// which this context resolves. A global key STS populates that this context +/// cannot resolve (for example `aws:PrincipalOrgID` or `aws:SourceIp`, neither +/// on the absent-ok allowlist) is reported as unresolvable (fail closed). Keys +/// that are legitimately absent for this caller model keep absent-key +/// semantics. `DynamoDB`-specific keys are not applicable. #[derive(Debug)] pub struct AssumeRoleContext { /// Tags on the calling principal. pub principal_tags: HashMap, /// The external ID provided in the `AssumeRole` call (if any). pub external_id: Option, + /// The session name provided on the `AssumeRole` call + /// (`sts:RoleSessionName`). STS populates this for every `AssumeRole`. + pub session_name: Option, + /// The caller ARN (`aws:PrincipalArn`). + pub principal_arn: String, + /// The caller's account id (`aws:PrincipalAccount`), the account field of + /// the caller ARN. `None` when the ARN has no account field. + pub principal_account: Option, + /// Session tags passed on the call (`aws:RequestTag/*`, and their keys for + /// `aws:TagKeys`). + pub request_tags: HashMap, + /// The STS region (`aws:RequestedRegion`), when known. + pub region: Option, + /// Whether the `AssumeRole` call arrived over TLS (`aws:SecureTransport`). + pub secure_transport: bool, + /// The calling principal's type (`aws:PrincipalType`): `User` for an IAM + /// user, `AssumedRole` for a role session. `None` when it cannot be + /// determined from the caller ARN (then it fails closed). + pub principal_type: Option, + /// The calling IAM user's name (`aws:username`); `None` when the caller is + /// not an IAM user (for example a role session), where it is absent. + pub username: Option, + /// The caller's source IP (`aws:SourceIp`): the peer address of the + /// `AssumeRole` connection, which is what STS records. + pub source_ip: Option, + /// The caller's `User-Agent` header (`aws:UserAgent`) on the `AssumeRole` + /// call. + pub user_agent: Option, + /// The caller's principal unique id (`aws:userid`), derived from the caller + /// ARN. `None` for a caller ARN that is neither a user nor an assumed-role + /// session. + pub userid: Option, + /// The account that owns the role being assumed (`aws:ResourceAccount`). + pub resource_account: Option, + /// The request time formatted for `aws:CurrentTime` (RFC 3339 UTC). + pub current_time: String, + /// The request time formatted for `aws:EpochTime` (whole seconds). + pub epoch_time: String, } impl ConditionContext for AssumeRoleContext { fn resolve_key(&self, key: &str) -> Option> { - if let Some(tag_key) = key.strip_prefix("aws:PrincipalTag/") { - self.principal_tags.get(tag_key).map(|v| vec![v.as_str()]) - } else if key == "sts:ExternalId" { - self.external_id.as_deref().map(|v| vec![v]) + // The live AssumeRole trust path matches condition key names and tag + // keys ASCII-case-insensitively only; unlike the DynamoDB request path + // it does NOT apply Unicode case folding (measured: a key spelled with a + // non-ASCII letter reads absent on live STS). Normalize with + // `to_ascii_lowercase`, not `case_fold`. + if let Some(sub) = strip_prefix_ci(key, "aws:PrincipalTag/") { + tag_lookup(&self.principal_tags, sub, str::to_ascii_lowercase) + } else if let Some(sub) = strip_prefix_ci(key, "aws:RequestTag/") { + tag_lookup(&self.request_tags, sub, str::to_ascii_lowercase) } else { - None + match key.to_ascii_lowercase().as_str() { + "sts:externalid" => self.external_id.as_deref().map(|v| vec![v]), + "sts:rolesessionname" => self.session_name.as_deref().map(|v| vec![v]), + "aws:principalarn" => Some(vec![self.principal_arn.as_str()]), + "aws:principalaccount" => self.principal_account.as_deref().map(|v| vec![v]), + "aws:requestedregion" => self.region.as_deref().map(|v| vec![v]), + "aws:securetransport" => Some(vec![if self.secure_transport { + "true" + } else { + "false" + }]), + "aws:currenttime" => Some(vec![self.current_time.as_str()]), + "aws:epochtime" => Some(vec![self.epoch_time.as_str()]), + "aws:principaltype" => self.principal_type.as_deref().map(|v| vec![v]), + "aws:username" => self.username.as_deref().map(|v| vec![v]), + "aws:sourceip" => self.source_ip.as_deref().map(|v| vec![v]), + "aws:useragent" => self.user_agent.as_deref().map(|v| vec![v]), + "aws:userid" => self.userid.as_deref().map(|v| vec![v]), + "aws:resourceaccount" => self.resource_account.as_deref().map(|v| vec![v]), + "aws:viaawsservice" => Some(vec!["false"]), + "aws:principalisawsservice" => Some(vec!["false"]), + "aws:tagkeys" => { + if self.request_tags.is_empty() { + None + } else { + Some(self.request_tags.keys().map(String::as_str).collect()) + } + } + _ => None, + } } } - fn is_multivalued_key(&self, _key: &str) -> bool { - // Trust-policy keys (`aws:PrincipalTag/*`, `sts:ExternalId`) are all - // single-valued. - false + fn is_multivalued_key(&self, key: &str) -> bool { + // `aws:TagKeys` is the set of session-tag keys (`ArrayOfString`); every + // other trust-policy key (`aws:PrincipalTag/*`, `aws:RequestTag/*`, + // `sts:ExternalId`, `aws:PrincipalArn`, `aws:PrincipalAccount`) is + // single-valued. Matched ASCII-case-insensitively, like the live trust + // path (no Unicode folding). + key.eq_ignore_ascii_case("aws:TagKeys") + } + + fn is_unresolvable_key(&self, key: &str) -> bool { + // Keys this context resolves (tags, external id, principal ARN and + // account, region, secure transport, time, principal type, user name) + // never reach here. Any other unresolved aws: global key that is not on + // the absent-ok allowlist is one STS populates but this context has no + // value for, so a Deny gated on it fails closed and an Allow does not + // grant. + aws_key_is_unresolvable(key) } } @@ -178,6 +513,92 @@ impl ConditionContext for AssumeRoleContext { mod tests { use super::*; + /// Build an `AssumeRoleContext` for an IAM user caller with the given tags, + /// external id and session tags, and the request-wide keys resolved. + fn ar_ctx( + principal_tags: HashMap, + external_id: Option, + request_tags: HashMap, + ) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags, + external_id, + session_name: Some("probe".to_owned()), + principal_arn: "arn:aws:iam::123456789012:user/alice".to_owned(), + principal_account: Some("123456789012".to_owned()), + request_tags, + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("aws-sdk-rust".to_owned()), + userid: Some("AIDAEXAMPLEUSERID1234".to_owned()), + resource_account: Some("123456789012".to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), + } + } + + #[test] + fn request_context_resolves_new_round3_keys() { + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("Botocore/1.0".to_owned()), + userid: Some("AIDAEXAMPLEUSERID1234".to_owned()), + resource_account: Some("123456789012".to_owned()), + ..Default::default() + }, + ); + assert_eq!(ctx.resolve_key("aws:SourceIp"), Some(vec!["203.0.113.9"])); + assert_eq!(ctx.resolve_key("aws:UserAgent"), Some(vec!["Botocore/1.0"])); + assert_eq!( + ctx.resolve_key("aws:userid"), + Some(vec!["AIDAEXAMPLEUSERID1234"]) + ); + assert_eq!( + ctx.resolve_key("aws:ResourceAccount"), + Some(vec!["123456789012"]) + ); + // Organization keys are absent for a standalone account, not + // unresolvable: they return None and do not fail closed. + assert_eq!(ctx.resolve_key("aws:PrincipalOrgID"), None); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgID")); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgPaths")); + } + + #[test] + fn request_context_token_issue_time_present_for_role_session_absent_for_user() { + // A role session carries aws:TokenIssueTime (the session issue time). + let role_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + token_issue_time: Some("2026-10-21T02:20:39Z".to_owned()), + ..Default::default() + }, + ); + assert_eq!( + role_ctx.resolve_key("aws:TokenIssueTime"), + Some(vec!["2026-10-21T02:20:39Z"]) + ); + // An IAM user leaves it absent: resolve_key returns None and the key is + // not fail-closed (standard absent-key semantics apply). + let user_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams::default(), + ); + assert_eq!(user_ctx.resolve_key("aws:TokenIssueTime"), None); + assert!(!user_ctx.is_unresolvable_key("aws:TokenIssueTime")); + } + #[test] fn request_context_principal_tag() { let ctx = RequestContext::build( @@ -193,6 +614,43 @@ mod tests { assert_eq!(ctx.resolve_key("aws:PrincipalTag/Missing"), None); } + #[test] + fn tag_lookup_fold_collision_is_deterministic_exact_wins() { + // mass and ma\u{00df} both fold to "mass". The exact stored key must win + // every time, and a non-exact fold-matching policy key must resolve to + // the smallest stored key by code point, regardless of HashMap seed. + let sharp = "ma\u{00df}"; + for _ in 0..64 { + let mut m = HashMap::new(); + m.insert("mass".to_owned(), "A".to_owned()); + m.insert(sharp.to_owned(), "B".to_owned()); + // exact byte match wins + assert_eq!( + tag_lookup(&m, "mass", crate::policy::casefold::case_fold), + Some(vec!["A"]) + ); + assert_eq!( + tag_lookup(&m, sharp, crate::policy::casefold::case_fold), + Some(vec!["B"]) + ); + // no exact: MASS folds to mass, matches both; smallest key "mass" wins + assert_eq!( + tag_lookup(&m, "MASS", crate::policy::casefold::case_fold), + Some(vec!["A"]) + ); + // ASCII normalizer (trust path): "mass" exact wins; sharp s is not + // folded to ss, so ma\u{00df} is a distinct key resolved exactly. + assert_eq!( + tag_lookup(&m, "mass", str::to_ascii_lowercase), + Some(vec!["A"]) + ); + assert_eq!( + tag_lookup(&m, sharp, str::to_ascii_lowercase), + Some(vec!["B"]) + ); + } + } + #[test] fn request_context_resource_tag() { let ctx = RequestContext::build( @@ -207,6 +665,201 @@ mod tests { ); } + #[test] + fn request_context_mfa_present_for_role_session_absent_for_user() { + // A role session carries aws:MultiFactorAuthPresent = false (assumed + // without MFA); an IAM user leaves it absent (standard absent-key + // semantics, not fail-closed). + let role_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + multi_factor_auth_present: Some(false), + ..Default::default() + }, + ); + assert_eq!( + role_ctx.resolve_key("aws:MultiFactorAuthPresent"), + Some(vec!["false"]) + ); + // Case-insensitive on the key name too. + assert_eq!( + role_ctx.resolve_key("aws:multifactorauthpresent"), + Some(vec!["false"]) + ); + let user_ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams::default(), + ); + assert_eq!(user_ctx.resolve_key("aws:MultiFactorAuthPresent"), None); + assert!(!user_ctx.is_unresolvable_key("aws:MultiFactorAuthPresent")); + } + + #[test] + fn request_context_tag_key_is_case_insensitive() { + // The service resolves the tag key case-insensitively; the value stays + // case-sensitive. Stored `Team`, policy names `team`. + let ctx = RequestContext::build( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + HashMap::from([("Env".to_owned(), "Prod".to_owned())]), + false, + RequestParams::default(), + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/team"), + Some(vec!["contractors"]) + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/TEAM"), + Some(vec!["contractors"]) + ); + assert_eq!( + ctx.resolve_key("dynamodb:ResourceTag/env"), + Some(vec!["Prod"]) + ); + // The value is not case-folded, only the key. + assert_eq!(ctx.resolve_key("aws:PrincipalTag/missing"), None); + } + + #[test] + fn assume_role_context_tag_key_is_case_insensitive() { + let ctx = ar_ctx( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + None, + HashMap::from([("Env".to_owned(), "prod".to_owned())]), + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/team"), + Some(vec!["contractors"]) + ); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + } + + #[test] + fn request_context_key_name_is_case_insensitive() { + // The service resolves a condition key NAME regardless of case, + // including the `aws:` prefix and the tag prefix. A miscased key must + // resolve to the same value as its canonical form. + let ctx = RequestContext::build( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + HashMap::from([("Env".to_owned(), "prod".to_owned())]), + false, + RequestParams { + source_ip: Some("203.0.113.9".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + leading_keys: Some(vec!["k1".to_owned()]), + attributes: Some(vec!["pk".to_owned()]), + ..Default::default() + }, + ); + // Global keys in lower case and with an upper-cased prefix. + assert_eq!(ctx.resolve_key("aws:securetransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("AWS:SecureTransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("aws:principaltype"), Some(vec!["User"])); + assert_eq!(ctx.resolve_key("aws:sourceip"), Some(vec!["203.0.113.9"])); + // dynamodb: keys in lower case, single- and multi-valued. + assert_eq!(ctx.resolve_key("dynamodb:leadingkeys"), Some(vec!["k1"])); + assert_eq!(ctx.resolve_key("dynamodb:attributes"), Some(vec!["pk"])); + assert!(ctx.is_multivalued_key("dynamodb:leadingkeys")); + assert!(ctx.is_multivalued_key("DYNAMODB:Attributes")); + // Tag prefixes in lower/upper case; the sub-key case is already folded. + assert_eq!( + ctx.resolve_key("aws:principaltag/team"), + Some(vec!["contractors"]) + ); + assert_eq!(ctx.resolve_key("aws:resourcetag/ENV"), Some(vec!["prod"])); + assert_eq!( + ctx.resolve_key("dynamodb:resourcetag/env"), + Some(vec!["prod"]) + ); + } + + #[test] + fn request_context_key_name_unicode_case_folds() { + // The service resolves a key name under Unicode full case folding. + // Long s and Kelvin and the st ligature and sharp s fold to their ASCII + // twins; the dotless and dotted i do NOT fold to i, matching the service + // (measured). + let ctx = RequestContext::build( + HashMap::from([("class".to_owned(), "x".to_owned())]), + HashMap::new(), + false, + RequestParams { + secure_transport: true, + principal_type: Some("User".to_owned()), + region: Some("us-east-1".to_owned()), + leading_keys: Some(vec!["k1".to_owned()]), + ..Default::default() + }, + ); + // long s (U+017F) in aws:SecureTran[s]port. + assert_eq!( + ctx.resolve_key("aws:SecureTran\u{017f}port"), + Some(vec!["true"]) + ); + // st ligature (U+FB05) in aws:Reque[st]edRegion. + assert_eq!( + ctx.resolve_key("aws:Reque\u{fb05}edRegion"), + Some(vec!["us-east-1"]) + ); + // Kelvin sign (U+212A) in dynamodb:Leading[k]eys. + assert!(ctx.is_multivalued_key("dynamodb:Leading\u{212a}eys")); + assert_eq!( + ctx.resolve_key("dynamodb:Leading\u{212a}eys"), + Some(vec!["k1"]) + ); + // sharp s (U+00DF) folds to ss in a tag key: cla[ß] matches stored class. + assert_eq!( + ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), + Some(vec!["x"]) + ); + // dotless i (U+0131) does NOT fold to i: the key reads absent. + assert_eq!(ctx.resolve_key("aws:Pr\u{0131}ncipalType"), None); + // dotted I (U+0130) does NOT fold to i either. + assert_eq!(ctx.resolve_key("aws:Pr\u{0130}ncipalType"), None); + } + + #[test] + fn assume_role_context_folds_ascii_only_not_unicode() { + // The trust path matches ASCII-case-insensitively only; it does NOT + // Unicode-fold, unlike the identity path. A key name or tag key spelled + // with a non-ASCII letter that would fold to a resolvable ASCII key + // reads as absent, matching live STS. + let ctx = ar_ctx( + HashMap::from([("class".to_owned(), "x".to_owned())]), + Some("ext-123".to_owned()), + HashMap::new(), + ); + assert_eq!(ctx.resolve_key("STS:EXTERNALID"), Some(vec!["ext-123"])); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/CLASS"), Some(vec!["x"])); + // Non-ASCII spellings do NOT resolve on the trust path. + assert_eq!(ctx.resolve_key("sts:External\u{0131}d"), None); + assert_eq!(ctx.resolve_key("aws:PrincipalTag/cla\u{00df}"), None); + assert_eq!(ctx.resolve_key("aws:Reque\u{fb05}edRegion"), None); + } + + #[test] + fn assume_role_context_key_name_is_case_insensitive() { + let ctx = ar_ctx( + HashMap::from([("Team".to_owned(), "contractors".to_owned())]), + Some("ext-123".to_owned()), + HashMap::from([("env".to_owned(), "prod".to_owned())]), + ); + assert_eq!(ctx.resolve_key("sts:externalid"), Some(vec!["ext-123"])); + assert_eq!(ctx.resolve_key("STS:ExternalId"), Some(vec!["ext-123"])); + assert_eq!( + ctx.resolve_key("aws:principalarn"), + Some(vec!["arn:aws:iam::123456789012:user/alice"]) + ); + assert_eq!(ctx.resolve_key("aws:requesttag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:tagkeys"), Some(vec!["env"])); + assert!(ctx.is_multivalued_key("aws:tagkeys")); + } + #[test] fn request_context_leading_keys() { let ctx = RequestContext::build( @@ -252,11 +905,166 @@ mod tests { #[test] fn assume_role_context_external_id() { - let ctx = AssumeRoleContext { - principal_tags: HashMap::new(), - external_id: Some("ext-123".to_owned()), - }; + let ctx = ar_ctx(HashMap::new(), Some("ext-123".to_owned()), HashMap::new()); assert_eq!(ctx.resolve_key("sts:ExternalId"), Some(vec!["ext-123"])); assert_eq!(ctx.resolve_key("dynamodb:LeadingKeys"), None); } + + #[test] + fn assume_role_context_principal_arn_and_account() { + let ctx = ar_ctx(HashMap::new(), None, HashMap::new()); + assert_eq!( + ctx.resolve_key("aws:PrincipalArn"), + Some(vec!["arn:aws:iam::123456789012:user/alice"]) + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalAccount"), + Some(vec!["123456789012"]) + ); + // An organization key is absent for a standalone account, not + // unresolvable: it returns None and keeps absent-key semantics. + assert_eq!(ctx.resolve_key("aws:PrincipalOrgID"), None); + assert!(!ctx.is_unresolvable_key("aws:PrincipalOrgID")); + assert!(!ctx.is_unresolvable_key("sts:ExternalId")); + assert!(!ctx.is_unresolvable_key("aws:PrincipalTag/team")); + } + + #[test] + fn assume_role_context_request_tags_and_tag_keys() { + let ctx = ar_ctx( + HashMap::new(), + None, + HashMap::from([("env".to_owned(), "prod".to_owned())]), + ); + assert_eq!(ctx.resolve_key("aws:RequestTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:RequestTag/missing"), None); + assert_eq!(ctx.resolve_key("aws:TagKeys"), Some(vec!["env"])); + assert!(ctx.is_multivalued_key("aws:TagKeys")); + // With no session tags, aws:TagKeys is genuinely absent, not + // unresolvable. + let empty = ar_ctx(HashMap::new(), None, HashMap::new()); + assert_eq!(empty.resolve_key("aws:TagKeys"), None); + assert!(!empty.is_unresolvable_key("aws:TagKeys")); + } + + fn user_ctx() -> RequestContext { + RequestContext::build( + HashMap::new(), + HashMap::from([("env".to_owned(), "prod".to_owned())]), + false, + RequestParams { + principal_arn: Some("arn:aws:iam::123456789012:user/alice".to_owned()), + principal_account: Some("123456789012".to_owned()), + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + ..Default::default() + }, + ) + } + + #[test] + fn request_context_resolves_request_wide_keys() { + let ctx = user_ctx(); + assert_eq!( + ctx.resolve_key("aws:RequestedRegion"), + Some(vec!["us-east-1"]) + ); + assert_eq!(ctx.resolve_key("aws:SecureTransport"), Some(vec!["true"])); + assert_eq!(ctx.resolve_key("aws:PrincipalType"), Some(vec!["User"])); + assert_eq!(ctx.resolve_key("aws:username"), Some(vec!["alice"])); + assert_eq!(ctx.resolve_key("aws:ViaAWSService"), Some(vec!["false"])); + assert_eq!( + ctx.resolve_key("aws:PrincipalIsAWSService"), + Some(vec!["false"]) + ); + // Time keys are populated (non-empty). + assert!( + ctx.resolve_key("aws:CurrentTime") + .is_some_and(|v| !v[0].is_empty()) + ); + assert!( + ctx.resolve_key("aws:EpochTime") + .is_some_and(|v| !v[0].is_empty()) + ); + // aws:ResourceTag/* resolves from the resource-tag map, case-insensitive key. + assert_eq!(ctx.resolve_key("aws:ResourceTag/env"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:ResourceTag/ENV"), Some(vec!["prod"])); + assert_eq!(ctx.resolve_key("aws:ResourceTag/missing"), None); + } + + #[test] + fn request_context_secure_transport_false_when_plaintext() { + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + secure_transport: false, + ..Default::default() + }, + ); + assert_eq!(ctx.resolve_key("aws:SecureTransport"), Some(vec!["false"])); + } + + #[test] + fn request_context_role_session_username_is_absent_not_unresolvable() { + // A role session has no user name; the service leaves aws:username + // absent, so it keeps absent-key semantics rather than failing closed. + let ctx = RequestContext::build( + HashMap::new(), + HashMap::new(), + false, + RequestParams { + principal_type: Some("AssumedRole".to_owned()), + username: None, + ..Default::default() + }, + ); + assert_eq!( + ctx.resolve_key("aws:PrincipalType"), + Some(vec!["AssumedRole"]) + ); + assert_eq!(ctx.resolve_key("aws:username"), None); + assert!(!ctx.is_unresolvable_key("aws:username")); + } + + #[test] + fn no_documented_global_key_fails_closed_after_round3() { + // The fail-closed set is empty. Every aws: global key is either resolved + // by the context or kept absent (absent-key semantics), matching the + // service; none is reported unresolvable. This includes the keys round + // two failed closed on (the organization keys, aws:userid, aws:SourceIp, + // aws:UserAgent), the keys the service leaves absent for a direct caller, + // an unknown key, tag sub-keys and non-aws: keys. + for k in [ + "aws:PrincipalOrgID", + "aws:PrincipalOrgPaths", + "aws:ResourceOrgID", + "aws:ResourceOrgPaths", + "aws:SourceOrgID", + "aws:SourceOrgPaths", + "aws:userid", + "aws:SourceIp", + "aws:UserAgent", + "aws:ResourceAccount", + "aws:MultiFactorAuthPresent", + "aws:MultiFactorAuthAge", + "aws:TokenIssueTime", + "aws:SourceVpc", + "aws:SourceVpce", + "aws:VpcSourceIp", + "aws:FederatedProvider", + "aws:PrincipalServiceName", + "aws:NoSuchGlobalKey", + "aws:PrincipalTag/team", + "aws:RequestTag/env", + "aws:ResourceTag/env", + "dynamodb:LeadingKeys", + "sts:ExternalId", + ] { + assert!(!aws_key_is_unresolvable(k), "{k} must not fail closed"); + } + } } diff --git a/crates/auth/src/policy/document.rs b/crates/auth/src/policy/document.rs index 195c60a9f..dedcd5e2a 100755 --- a/crates/auth/src/policy/document.rs +++ b/crates/auth/src/policy/document.rs @@ -93,6 +93,7 @@ pub enum ConditionOperator { StringEquals, StringNotEquals, StringEqualsIgnoreCase, + StringNotEqualsIgnoreCase, StringLike, StringNotLike, // Numeric @@ -118,6 +119,9 @@ pub enum ConditionOperator { ArnNotEquals, ArnLike, ArnNotLike, + // IP address (CIDR membership, IPv4 and IPv6) + IpAddress, + NotIpAddress, // Set operators (prefix applied to any of the above) ForAllValues(Box), ForAnyValue(Box), @@ -170,11 +174,95 @@ impl PolicyDocument { let raw: Value = serde_json::from_str(json).map_err(|e| PolicyParseError(e.to_string()))?; let version = raw["Version"].as_str().unwrap_or("2012-10-17").to_owned(); let statements = parse_statements(&raw["Statement"])?; + validate_statement_cidrs(&statements)?; Ok(Self { version, statements, }) } + + /// Parse a policy document for a write (`PutUserPolicy`, `PutRolePolicy`, + /// a managed or boundary policy) and apply the checks the service applies + /// only when a policy is written. + /// + /// Everything `from_json` checks, plus the `Sid` charset. The `Sid` check + /// runs at write only: a `Sid` is a statement label that does not affect + /// evaluation, so a policy stored before this check existed must keep + /// parsing on the request path rather than fail the principal closed. + /// + /// # Errors + /// + /// Returns `PolicyParseError` if `from_json` rejects the document or a + /// statement `Sid` is outside the service's charset. + pub fn from_json_for_write(json: &str) -> Result { + let doc = Self::from_json(json)?; + validate_statement_sids(&doc.statements)?; + Ok(doc) + } +} + +/// Unwrap a condition operator to its base, dropping any `ForAllValues:` / +/// `ForAnyValue:` prefix and `IfExists` suffix. +fn base_operator(op: &ConditionOperator) -> &ConditionOperator { + match op { + ConditionOperator::ForAllValues(inner) + | ConditionOperator::ForAnyValue(inner) + | ConditionOperator::IfExists(inner) => base_operator(inner), + other => other, + } +} + +/// Reject a malformed CIDR in an `IpAddress` or `NotIpAddress` condition value. +/// +/// The live service validates these values when an identity or managed policy +/// is written ("legacy parsing") and rejects a malformed one with +/// `MalformedPolicyDocument`. `PolicyDocument::from_json` is the identity-policy +/// write and read path, so this rejects a malformed value at write and fails a +/// stored identity policy closed at read (it no longer parses, so the request +/// is denied). The trust-policy path does not use `from_json`: the live trust +/// write accepts a malformed CIDR and the trust evaluator drops the statement, +/// which ExtendDB already matches, so trust policies are intentionally not +/// validated here. +fn validate_statement_cidrs(statements: &[Statement]) -> Result<(), PolicyParseError> { + for stmt in statements { + for cond in &stmt.conditions { + if matches!( + base_operator(&cond.operator), + ConditionOperator::IpAddress | ConditionOperator::NotIpAddress + ) { + for v in &cond.values { + if !super::matcher::is_valid_cidr(v) { + return Err(PolicyParseError(format!( + "malformed CIDR in condition {}: {v}", + cond.key + ))); + } + } + } + } + } + Ok(()) +} + +/// Reject a `Sid` outside the service's charset. +/// +/// Measured on live IAM (`PutUserPolicy` and `CreateRole`): a `Sid` is accepted +/// only when it is empty or entirely ASCII letters and digits (`[A-Za-z0-9]`), +/// regardless of length; a hyphen, underscore, space, dot or any non-ASCII +/// character (including Latin-1 such as an accented letter) is rejected with +/// `MalformedPolicyDocument`. Applied by `from_json_for_write` only, never on +/// the request path, so a stored policy with such a `Sid` keeps evaluating. +fn validate_statement_sids(statements: &[Statement]) -> Result<(), PolicyParseError> { + for stmt in statements { + if let Some(sid) = &stmt.sid + && !sid.chars().all(|c| c.is_ascii_alphanumeric()) + { + return Err(PolicyParseError(format!( + "invalid Sid (must be ASCII letters and digits only): {sid}" + ))); + } + } + Ok(()) } /// Parse the Statement field — may be a single object or an array. @@ -357,6 +445,7 @@ fn parse_base_operator(name: &str) -> Result Ok(ConditionOperator::StringEquals), "StringNotEquals" => Ok(ConditionOperator::StringNotEquals), "StringEqualsIgnoreCase" => Ok(ConditionOperator::StringEqualsIgnoreCase), + "StringNotEqualsIgnoreCase" => Ok(ConditionOperator::StringNotEqualsIgnoreCase), "StringLike" => Ok(ConditionOperator::StringLike), "StringNotLike" => Ok(ConditionOperator::StringNotLike), "NumericEquals" => Ok(ConditionOperator::NumericEquals), @@ -377,6 +466,8 @@ fn parse_base_operator(name: &str) -> Result Ok(ConditionOperator::ArnNotEquals), "ArnLike" => Ok(ConditionOperator::ArnLike), "ArnNotLike" => Ok(ConditionOperator::ArnNotLike), + "IpAddress" => Ok(ConditionOperator::IpAddress), + "NotIpAddress" => Ok(ConditionOperator::NotIpAddress), _ => Err(PolicyParseError(format!("unknown operator: {name}"))), } } @@ -549,4 +640,113 @@ mod tests { Some(PrincipalMatch::Principals(p)) if p == &["*"] )); } + + #[test] + fn reject_malformed_cidr_in_ipaddress() { + // The live service rejects these at identity-policy write with + // MalformedPolicyDocument; from_json is the write and read path. + for cidr in ["10.0.0.0/33", "999.0.0.0/8", "not-a-cidr", "10.0.0.0/"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Deny", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"IpAddress":{{"aws:SourceIp":"{cidr}"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_err(), + "should reject malformed CIDR {cidr}" + ); + } + } + + #[test] + fn reject_malformed_cidr_in_notipaddress_and_set_operator() { + let json = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Deny", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"ForAnyValue:NotIpAddress":{"aws:SourceIp":"10.0.0.0/33"}}}]}"#; + assert!(PolicyDocument::from_json(json).is_err()); + } + + #[test] + fn accept_well_formed_cidr() { + let json = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"IpAddress":{"aws:SourceIp":["10.0.0.0/8","2001:db8::/32"]}}}]}"#; + assert!(PolicyDocument::from_json(json).is_ok()); + } + + #[test] + fn reject_non_alphanumeric_sid() { + for sid in ["Sid-X", "Sid_X", "Sid X", "Sid.X", "Sid\u{00e9}"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# + ); + assert!( + PolicyDocument::from_json_for_write(&json).is_err(), + "should reject Sid {sid:?} at write" + ); + // A policy stored before the check existed still parses on the + // request path, so its principal is not locked out. + assert!( + PolicyDocument::from_json(&json).is_ok(), + "stored policy with Sid {sid:?} must still parse" + ); + } + } + + #[test] + fn accept_alphanumeric_or_empty_sid() { + for sid in ["Abc123", "", "aaaaaaaaaaaaaaaaaaaa"] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Sid":"{sid}","Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}}]}}"# + ); + assert!( + PolicyDocument::from_json_for_write(&json).is_ok(), + "should accept Sid {sid:?}" + ); + } + } + + #[test] + fn reject_miscased_operator_names() { + // The live service treats operator names, set qualifiers and the + // IfExists suffix as case-sensitive and rejects a miscased one with + // MalformedPolicyDocument (measured). ExtendDB rejects the same + // at parse (identity-policy write and read). + for op in [ + "stringequals", + "STRINGEQUALS", + "bool", + "forallvalues:StringEquals", + "ForAllValues:stringequals", + "StringEqualsifexists", + "StringEqualsIfexists", + ] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{op}":{{"aws:PrincipalType":"User"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_err(), + "miscased operator {op} must be rejected" + ); + } + // Canonical operators, qualifier and suffix are accepted. + for op in [ + "StringEquals", + "Bool", + "ForAllValues:StringEquals", + "StringEqualsIfExists", + ] { + let json = format!( + r#"{{"Version":"2012-10-17","Statement":[{{"Effect":"Allow", + "Action":"dynamodb:GetItem","Resource":"*", + "Condition":{{"{op}":{{"aws:PrincipalType":"User"}}}}}}]}}"# + ); + assert!( + PolicyDocument::from_json(&json).is_ok(), + "canonical operator {op} must be accepted" + ); + } + } } diff --git a/crates/auth/src/policy/evaluator.rs b/crates/auth/src/policy/evaluator.rs index b49c440ff..df321dedf 100644 --- a/crates/auth/src/policy/evaluator.rs +++ b/crates/auth/src/policy/evaluator.rs @@ -7,7 +7,7 @@ //! session policy → identity allow → implicit deny. This is the same algorithm //! used by real AWS IAM, supporting IBAC, RBAC, and ABAC patterns. -use super::condition::evaluate_condition; +use super::condition::{ConditionMatch, evaluate_statement_conditions}; use super::context::ConditionContext; use super::document::{ActionMatch, Effect, PolicyDocument, ResourceMatch, Statement}; use super::matcher::{arn_match, wildcard_match_ignore_case}; @@ -101,7 +101,7 @@ where if stmt.effect == Effect::Deny && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && deny_conditions_apply(stmt, context) { return AuthzDecision::Deny; } @@ -114,7 +114,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) }); if !boundary_allows { return AuthzDecision::Deny; @@ -127,7 +127,7 @@ where stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) }); if !session_allows { return AuthzDecision::Deny; @@ -140,7 +140,7 @@ where if stmt.effect == Effect::Allow && action_matches(stmt, action) && resource_matches(stmt, resource_arn) - && conditions_match(stmt, context) + && allow_conditions_grant(stmt, context) { return AuthzDecision::Allow; } @@ -176,12 +176,28 @@ fn resource_matches(statement: &Statement, request_resource: &str) -> bool { } } -/// Check if all conditions in the statement are satisfied. -fn conditions_match(statement: &Statement, context: &impl ConditionContext) -> bool { - statement - .conditions - .iter() - .all(|c| evaluate_condition(c, context)) +/// Outcome of a statement's `Condition` block, three-valued. +fn statement_conditions(statement: &Statement, context: &impl ConditionContext) -> ConditionMatch { + evaluate_statement_conditions(&statement.conditions, context) +} + +/// Whether a `Deny` applies: its conditions match, or depend on a key the +/// service populates but the context cannot resolve (fail closed, so the Deny +/// blocks rather than being silently dropped). +fn deny_conditions_apply(statement: &Statement, context: &impl ConditionContext) -> bool { + matches!( + statement_conditions(statement, context), + ConditionMatch::Match | ConditionMatch::Unresolvable + ) +} + +/// Whether an `Allow` grants: its conditions definitely match. An unresolvable +/// key does not grant (fail closed). +fn allow_conditions_grant(statement: &Statement, context: &impl ConditionContext) -> bool { + matches!( + statement_conditions(statement, context), + ConditionMatch::Match + ) } #[cfg(test)] diff --git a/crates/auth/src/policy/evaluator/tests.rs b/crates/auth/src/policy/evaluator/tests.rs index 62eaa2dee..dcc8e403e 100644 --- a/crates/auth/src/policy/evaluator/tests.rs +++ b/crates/auth/src/policy/evaluator/tests.rs @@ -9,25 +9,37 @@ use crate::policy::document::PolicyDocument; use std::collections::HashMap; /// Simple test context. -struct Ctx(HashMap>); +struct Ctx { + values: HashMap>, + unresolvable: std::collections::HashSet, +} impl Ctx { fn empty() -> Self { - Self(HashMap::new()) + Self { + values: HashMap::new(), + unresolvable: std::collections::HashSet::new(), + } } fn with(mut self, key: &str, values: Vec<&str>) -> Self { - self.0.insert( + self.values.insert( key.to_owned(), values.into_iter().map(ToOwned::to_owned).collect(), ); self } + + /// Mark `key` as service-populated but unresolvable (case 3). + fn unresolvable(mut self, key: &str) -> Self { + self.unresolvable.insert(key.to_owned()); + self + } } impl ConditionContext for Ctx { fn resolve_key(&self, key: &str) -> Option> { - self.0 + self.values .get(key) .map(|v| v.iter().map(|s| s.as_str()).collect()) } @@ -35,11 +47,16 @@ impl ConditionContext for Ctx { fn is_multivalued_key(&self, key: &str) -> bool { matches!(key, "dynamodb:LeadingKeys" | "dynamodb:Attributes") } + + fn is_unresolvable_key(&self, key: &str) -> bool { + self.unresolvable.contains(key) + } } fn parse(json: &str) -> PolicyDocument { PolicyDocument::from_json(json).unwrap() } +mod case_three; mod combined; mod core; diff --git a/crates/auth/src/policy/evaluator/tests/case_three.rs b/crates/auth/src/policy/evaluator/tests/case_three.rs new file mode 100644 index 000000000..f6ab83e30 --- /dev/null +++ b/crates/auth/src/policy/evaluator/tests/case_three.rs @@ -0,0 +1,122 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Identity-policy evaluation of a key the service populates but the context +//! cannot resolve (case 3): a Deny fails closed and blocks, an Allow gated on +//! it does not grant, and the negated-operator-on-absent rule cannot fire. + +use super::*; + +const RES: &str = "arn:aws:dynamodb:us-east-1:123456789012:table/T"; + +/// Allow dynamodb:* plus a Deny GetItem gated on StringEquals aws:PrincipalOrgID. +fn allow_all_deny_get_on_orgid() -> PolicyDocument { + parse( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}, + {"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalOrgID":"o-example1"}}} + ]}"#, + ) +} + +#[test] +fn deny_on_unresolvable_orgid_blocks_fail_closed() { + // The org id is populated by the service but unresolvable here, so the Deny + // fails closed and blocks GetItem. + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies( + &[allow_all_deny_get_on_orgid()], + None, + None, + "dynamodb:GetItem", + RES, + &ctx + ), + AuthzDecision::Deny + ); + // A different action the Deny does not name is still allowed. + assert_eq!( + evaluate_policies( + &[allow_all_deny_get_on_orgid()], + None, + None, + "dynamodb:PutItem", + RES, + &ctx + ), + AuthzDecision::Allow + ); +} + +#[test] +fn allow_gated_on_unresolvable_orgid_does_not_grant() { + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalOrgID":"o-example1"}} + }]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn negated_allow_on_unresolvable_key_does_not_overgrant() { + // StringNotEquals on an unresolvable key must not grant via the + // negated-absent rule: the Allow does not grant, so the request is denied. + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringNotEquals":{"aws:PrincipalOrgID":"o-example1"}} + }]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn negated_deny_on_unresolvable_key_fails_closed() { + // A Deny with a negated operator on an unresolvable key fails closed and + // blocks (over-denies relative to the service, the documented safe + // direction) rather than being dropped. + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":"dynamodb:*","Resource":"*"}, + {"Effect":"Deny","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringNotEquals":{"aws:PrincipalOrgID":"o-example1"}}} + ]}"#, + ); + let ctx = Ctx::empty().unresolvable("aws:PrincipalOrgID"); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Deny + ); +} + +#[test] +fn resolved_principal_arn_allows_normally() { + // aws:PrincipalArn is resolvable on the identity path; a matching Allow + // grants (not fail closed). + let policy = parse( + r#"{"Version":"2012-10-17","Statement":[{ + "Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*", + "Condition":{"StringEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:user/alice"}} + }]}"#, + ); + let ctx = Ctx::empty().with( + "aws:PrincipalArn", + vec!["arn:aws:iam::123456789012:user/alice"], + ); + assert_eq!( + evaluate_policies(&[policy], None, None, "dynamodb:GetItem", RES, &ctx), + AuthzDecision::Allow + ); +} diff --git a/crates/auth/src/policy/matcher.rs b/crates/auth/src/policy/matcher.rs index 3e97d98e7..18e89a6e9 100755 --- a/crates/auth/src/policy/matcher.rs +++ b/crates/auth/src/policy/matcher.rs @@ -126,6 +126,140 @@ pub fn arn_match(pattern: &str, value: &str) -> bool { .all(|(p, v)| wildcard_match(p, v)) } +/// Whether `addr` falls inside the CIDR `cidr`, for the `IpAddress` and +/// `NotIpAddress` condition operators. +/// +/// `cidr` is an IPv4 or IPv6 network in `address/prefix` form, or a bare +/// address (treated as a full-length prefix). Returns: +/// - `Some(true)` when `addr` and `cidr` are the same family and `addr` is in +/// the network, +/// - `Some(false)` when they are the same family and `addr` is not in it, +/// - `None` when either value does not parse or the families differ. +/// +/// The family-mismatch `None` matters: the live service, given an IPv4 policy +/// range and an IPv6 caller (or the reverse), matches neither `IpAddress` nor +/// `NotIpAddress` (both evaluate false), rather than letting `NotIpAddress` +/// pass by negation. Measured live on this account: an IPv4 `IpAddress` range +/// and an IPv4 `NotIpAddress` range both fail to apply for an IPv6 caller. +/// Whether `cidr` is a well-formed IPv4 or IPv6 network in `address/prefix` +/// form, or a bare address. +/// +/// The live service validates `IpAddress` and `NotIpAddress` values in an +/// identity or managed policy at write ("legacy parsing"), rejecting a bad +/// address, a bad or empty prefix, or a prefix out of range for the family +/// (`/33` on IPv4, `/129` on IPv6) with `MalformedPolicyDocument`. This mirrors +/// the parse rules `ip_in_cidr` applies, so a value that passes here is one it +/// can evaluate. +#[must_use] +pub fn is_valid_cidr(cidr: &str) -> bool { + let (net_str, prefix_str) = match cidr.split_once('/') { + Some((n, p)) => (n, Some(p)), + None => (cidr, None), + }; + let net = match parse_network(net_str) { + Some(n) => n, + None => return false, + }; + match prefix_str { + None => true, + Some(p) => match (net, p.parse::()) { + (std::net::IpAddr::V4(_), Ok(prefix)) => prefix <= 32, + (std::net::IpAddr::V6(_), Ok(prefix)) => prefix <= 128, + _ => false, + }, + } +} + +/// Parse an IPv4 address/network literal reading each octet as DECIMAL, so a +/// leading-zero octet is the same value as without it (`072` is 72, not octal +/// 58, and `0110` is 110). Measured: the live service reads a leading-zero octet +/// in an `IpAddress` value as decimal. Rejects a wrong octet count, an empty or +/// non-digit octet, or an octet greater than 255. +fn parse_ipv4_decimal(s: &str) -> Option { + let mut octets = [0u8; 4]; + let mut parts = s.split('.'); + for octet in &mut octets { + let part = parts.next()?; + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let val: u32 = part.parse().ok()?; + if val > 255 { + return None; + } + *octet = u8::try_from(val).ok()?; + } + if parts.next().is_some() { + return None; + } + Some(std::net::Ipv4Addr::from(octets)) +} + +/// Parse a CIDR network literal, matching the live service. An IPv4 literal is +/// read with decimal octets (`parse_ipv4_decimal`); an IPv6 literal is parsed by +/// the standard library (upper-case hex accepted), but an IPv4-mapped IPv6 +/// address (`::ffff:a.b.c.d`) is rejected, because the service rejects it with +/// `MalformedPolicyDocument` and accepting it would store an `IpAddress` Deny +/// that silently never matches (the caller source IP is canonicalized to a bare +/// IPv4 of a different family). +fn parse_network(net_str: &str) -> Option { + use std::net::{IpAddr, Ipv6Addr}; + if net_str.contains(':') { + let v6: Ipv6Addr = net_str.parse().ok()?; + if v6.to_ipv4_mapped().is_some() { + return None; + } + Some(IpAddr::V6(v6)) + } else { + parse_ipv4_decimal(net_str).map(IpAddr::V4) + } +} + +#[must_use] +pub fn ip_in_cidr(cidr: &str, addr: &str) -> Option { + use std::net::IpAddr; + + let ip: IpAddr = addr.parse().ok()?; + let (net_str, prefix_str) = match cidr.split_once('/') { + Some((n, p)) => (n, Some(p)), + None => (cidr, None), + }; + let net: IpAddr = parse_network(net_str)?; + match (ip, net) { + (IpAddr::V4(ip), IpAddr::V4(net)) => { + let prefix: u32 = match prefix_str { + Some(p) => p.parse().ok()?, + None => 32, + }; + if prefix > 32 { + return None; + } + let mask: u32 = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Some((u32::from(ip) & mask) == (u32::from(net) & mask)) + } + (IpAddr::V6(ip), IpAddr::V6(net)) => { + let prefix: u32 = match prefix_str { + Some(p) => p.parse().ok()?, + None => 128, + }; + if prefix > 128 { + return None; + } + let mask: u128 = if prefix == 0 { + 0 + } else { + u128::MAX << (128 - prefix) + }; + Some((u128::from(ip) & mask) == (u128::from(net) & mask)) + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -268,4 +402,92 @@ mod tests { "arn:aws:dynamodb:us-east-1:123:table/Users" )); } + + // --- ip_in_cidr --- + + #[test] + fn ipv4_membership() { + assert_eq!(ip_in_cidr("203.0.113.0/24", "203.0.113.9"), Some(true)); + assert_eq!(ip_in_cidr("203.0.113.0/24", "203.0.114.9"), Some(false)); + assert_eq!(ip_in_cidr("0.0.0.0/0", "52.94.133.133"), Some(true)); + // Bare address is a full-length (/32) prefix. + assert_eq!(ip_in_cidr("52.94.133.133", "52.94.133.133"), Some(true)); + assert_eq!(ip_in_cidr("52.94.133.133/32", "52.94.133.134"), Some(false)); + } + + #[test] + fn ipv6_membership() { + assert_eq!(ip_in_cidr("2001:db8::/32", "2001:db8::1"), Some(true)); + assert_eq!(ip_in_cidr("2001:db8::/32", "2001:dead::1"), Some(false)); + assert_eq!(ip_in_cidr("::/0", "2001:db8::1"), Some(true)); + } + + #[test] + fn family_mismatch_is_none() { + // An IPv4 range and an IPv6 caller (and the reverse) are incomparable: + // neither IpAddress nor NotIpAddress applies. + assert_eq!(ip_in_cidr("203.0.113.0/24", "2001:db8::1"), None); + assert_eq!(ip_in_cidr("2001:db8::/32", "203.0.113.9"), None); + } + + #[test] + fn malformed_is_none() { + assert_eq!(ip_in_cidr("not-a-cidr", "203.0.113.9"), None); + assert_eq!(ip_in_cidr("203.0.113.0/24", "not-an-ip"), None); + assert_eq!(ip_in_cidr("203.0.113.0/33", "203.0.113.9"), None); + } + + #[test] + fn valid_cidr_rejects_mapped_ipv6_and_accepts_upper_and_padded() { + // The service rejects an IPv4-mapped IPv6 literal in a policy CIDR with + // MalformedPolicyDocument (measured). + assert!(!is_valid_cidr("::ffff:10.0.0.0/120")); + assert!(!is_valid_cidr("::ffff:72.21.196.65/128")); + assert!(!is_valid_cidr("::ffff:10.0.0.0")); + // Upper-case IPv6 hex and a zero-padded prefix are accepted (match service). + assert!(is_valid_cidr("2001:DB8::/32")); + assert!(is_valid_cidr("10.0.0.0/08")); + // Genuine IPv6 networks are still accepted. + assert!(is_valid_cidr("2001:db8::/32")); + assert!(is_valid_cidr("::/0")); + // A leading-zero octet is accepted and read as decimal (measured: the + // service reads 072 as 72, 0110 as 110). + assert!(is_valid_cidr("010.0.0.0/8")); + assert!(is_valid_cidr("072.021.196.065/32")); + assert!(is_valid_cidr("0110.0.0.0/8")); + // An octet over 255 is still rejected. + assert!(!is_valid_cidr("10.0.0.256/8")); + assert!(!is_valid_cidr("0256.0.0.0/8")); + assert!(!is_valid_cidr("10.0.0.0/33")); + } + + #[test] + fn ip_in_cidr_reads_leading_zero_octet_as_decimal() { + // 072.0.0.0/8 is the 72.0.0.0/8 network (decimal), not octal 58. + assert_eq!(ip_in_cidr("072.0.0.0/8", "72.21.196.65"), Some(true)); + assert_eq!(ip_in_cidr("072.0.0.0/8", "58.0.0.1"), Some(false)); + // 0110.0.0.0/8 is decimal 110, not octal 72. + assert_eq!(ip_in_cidr("0110.0.0.0/8", "110.1.2.3"), Some(true)); + assert_eq!(ip_in_cidr("0110.0.0.0/8", "72.1.2.3"), Some(false)); + // A mapped IPv6 network does not parse, so neither operator applies. + assert_eq!(ip_in_cidr("::ffff:10.0.0.0/120", "10.0.0.1"), None); + } + + #[test] + fn valid_cidr_accepts_well_formed_and_rejects_malformed() { + // Well-formed (accepted at write). + assert!(is_valid_cidr("10.0.0.0/8")); + assert!(is_valid_cidr("0.0.0.0/0")); + assert!(is_valid_cidr("203.0.113.9")); + assert!(is_valid_cidr("203.0.113.9/32")); + assert!(is_valid_cidr("2001:db8::/32")); + assert!(is_valid_cidr("::/0")); + // Malformed, the exact set the live service rejects with + // MalformedPolicyDocument. + assert!(!is_valid_cidr("10.0.0.0/33")); + assert!(!is_valid_cidr("999.0.0.0/8")); + assert!(!is_valid_cidr("not-a-cidr")); + assert!(!is_valid_cidr("10.0.0.0/")); + assert!(!is_valid_cidr("2001:db8::/129")); + } } diff --git a/crates/auth/src/policy/mod.rs b/crates/auth/src/policy/mod.rs index 9af3ab1c9..2eac6a912 100755 --- a/crates/auth/src/policy/mod.rs +++ b/crates/auth/src/policy/mod.rs @@ -7,8 +7,10 @@ //! permissions boundary → session policy → identity allow → implicit deny. //! Supports IBAC, RBAC, and ABAC/FGAC patterns through a unified evaluator. +pub mod casefold; pub mod condition; pub mod context; pub mod document; pub mod evaluator; pub mod matcher; +pub mod principal_id; diff --git a/crates/auth/src/policy/principal_id.rs b/crates/auth/src/policy/principal_id.rs new file mode 100644 index 000000000..170d42a5c --- /dev/null +++ b/crates/auth/src/policy/principal_id.rs @@ -0,0 +1,233 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Principal unique identifiers for the `aws:userid` condition key. +//! +//! The live service populates `aws:userid` with the principal's unique id: for +//! an IAM user the user's id (an `AIDA...` string), and for a role session the +//! pair `:` where the role id is an `AROA...` string. +//! Measured live on this account: an IAM user resolves to `AIDA` followed by a +//! base32 body, and an assumed-role session to `AROA...:` (for +//! example `AROA6A7B6LB4GWNTMCATH:r3probe`). +//! +//! ExtendDB does not store a unique id for users or roles (the catalog keys a +//! user by `(account_id, user_name)` and a role by `(account_id, role_name)`), +//! and it exposes no surface, such as an STS `GetCallerIdentity`, that returns +//! one. The id therefore has no external value a customer could already have +//! read, so the only contract `aws:userid` must honour is the service's: an +//! `AIDA`/`AROA` string that is stable for a principal, unique across +//! principals, and the same body for a user and for a role session built from +//! that role. This module derives it deterministically from the principal's +//! stable catalog identity, so every existing user and role has one with no +//! stored column and no catalog migration; the derivation is the source of +//! truth. + +use sha2::{Digest, Sha256}; + +/// RFC 4648 base32 alphabet (uppercase, no padding), the character set the +/// service's principal ids use. +const BASE32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/// The body length (characters after the four-character type prefix). The +/// service's ids are 21 characters (for example `AROA` plus a 17-character +/// body), which this matches. +const BODY_LEN: usize = 17; + +/// Base32-encode the first `n` characters of `data`. +fn base32_prefix(data: &[u8], n: usize) -> String { + let mut buffer: u32 = 0; + let mut bits_left: u32 = 0; + let mut out = String::with_capacity(n); + for &byte in data { + buffer = (buffer << 8) | u32::from(byte); + bits_left += 8; + while bits_left >= 5 { + bits_left -= 5; + let idx = ((buffer >> bits_left) & 0x1f) as usize; + out.push(BASE32[idx] as char); + if out.len() == n { + return out; + } + } + buffer &= (1 << bits_left) - 1; + } + out +} + +/// Derive an id body from a domain-separated set of stable parts. +fn id_body(parts: &[&str]) -> String { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part.as_bytes()); + // A length-prefixed separator keeps ("ab","c") distinct from ("a","bc"). + hasher.update([0u8]); + } + base32_prefix(&hasher.finalize(), BODY_LEN) +} + +/// The `aws:userid` value for an IAM user: its unique id, an `AIDA...` string. +/// +/// `created_at` is the user's creation time (any stable representation, in +/// practice RFC 3339). Including it means a user deleted and recreated with the +/// same name gets a different id, as IAM issues a new unique id each time. +#[must_use] +pub fn user_unique_id(account_id: &str, user_name: &str, created_at: &str) -> String { + format!( + "AIDA{}", + id_body(&["user", account_id, user_name, created_at]) + ) +} + +/// A role's unique id, an `AROA...` string. Used as the first half of a role +/// session's `aws:userid`. `created_at` is the role's creation time, so a +/// recreated role gets a new id. +#[must_use] +pub fn role_unique_id(account_id: &str, role_name: &str, created_at: &str) -> String { + format!( + "AROA{}", + id_body(&["role", account_id, role_name, created_at]) + ) +} + +/// The `aws:userid` value for a role session: `:`. +/// +/// `role_created_at` is the role's creation time (not the session's): the role +/// id half changes when the role is recreated, matching IAM. +#[must_use] +pub fn role_session_userid( + account_id: &str, + role_name: &str, + session_name: &str, + role_created_at: &str, +) -> String { + format!( + "{}:{session_name}", + role_unique_id(account_id, role_name, role_created_at) + ) +} + +/// The `aws:userid` value for a caller identified by ARN, for the trust path. +/// +/// An IAM user ARN (`arn:aws:iam:::user/`) resolves to the +/// user's `AIDA...` id; an assumed-role ARN +/// (`arn:aws:sts:::assumed-role//`) to +/// `:`. `created_at` is the caller principal's creation time +/// (the user's for a user ARN, the role's for an assumed-role ARN), looked up +/// by the caller; when the caller is not a known local principal it is `None` +/// and the id is derived without it. Any other ARN form returns `None`, so a +/// trust policy conditioning on `aws:userid` for such a caller fails to match +/// rather than resolving to a fabricated value. +#[must_use] +pub fn userid_for_caller_arn(caller_arn: &str, created_at: Option<&str>) -> Option { + let parts: Vec<&str> = caller_arn.splitn(6, ':').collect(); + if parts.len() < 6 { + return None; + } + let account = parts[4]; + let resource = parts[5]; + let created_at = created_at.unwrap_or(""); + if let Some(user_name) = resource.strip_prefix("user/") { + Some(user_unique_id(account, user_name, created_at)) + } else if let Some(rest) = resource.strip_prefix("assumed-role/") { + let (role, session) = rest.split_once('/')?; + Some(role_session_userid(account, role, session, created_at)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const T1: &str = "2026-10-21T00:00:00Z"; + const T2: &str = "2026-10-21T01:00:00Z"; + + #[test] + fn user_id_has_the_service_prefix_and_length() { + let id = user_unique_id("123456789012", "alice", T1); + assert!(id.starts_with("AIDA"), "{id}"); + assert_eq!(id.len(), 4 + BODY_LEN); + assert!( + id.bytes() + .all(|b| BASE32.contains(&b) || b"AIDA".contains(&b)) + ); + } + + #[test] + fn role_id_has_the_service_prefix_and_length() { + let id = role_unique_id("123456789012", "app-role", T1); + assert!(id.starts_with("AROA"), "{id}"); + assert_eq!(id.len(), 4 + BODY_LEN); + } + + #[test] + fn ids_are_stable_and_unique() { + assert_eq!( + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "alice", T1) + ); + assert_ne!( + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "bob", T1) + ); + assert_ne!( + user_unique_id("123456789012", "alice", T1), + user_unique_id("999999999999", "alice", T1) + ); + // A user and a role with the same name get different ids (different + // type prefix and domain separation). + assert_ne!( + user_unique_id("123456789012", "shared", T1), + role_unique_id("123456789012", "shared", T1) + ); + } + + #[test] + fn recreating_a_principal_changes_its_id() { + // Same account and name, different creation time: a new id, as IAM + // issues on delete and recreate. + assert_ne!( + user_unique_id("123456789012", "alice", T1), + user_unique_id("123456789012", "alice", T2) + ); + assert_ne!( + role_unique_id("123456789012", "app-role", T1), + role_unique_id("123456789012", "app-role", T2) + ); + } + + #[test] + fn role_session_userid_is_role_id_colon_session() { + let rid = role_unique_id("123456789012", "app-role", T1); + assert_eq!( + role_session_userid("123456789012", "app-role", "sess-1", T1), + format!("{rid}:sess-1") + ); + } + + #[test] + fn userid_for_caller_arn_covers_user_and_role_session() { + assert_eq!( + userid_for_caller_arn("arn:aws:iam::123456789012:user/alice", Some(T1)), + Some(user_unique_id("123456789012", "alice", T1)) + ); + assert_eq!( + userid_for_caller_arn( + "arn:aws:sts::123456789012:assumed-role/app-role/sess-1", + Some(T1) + ), + Some(role_session_userid( + "123456789012", + "app-role", + "sess-1", + T1 + )) + ); + assert_eq!( + userid_for_caller_arn("arn:aws:iam::123456789012:root", Some(T1)), + None + ); + assert_eq!(userid_for_caller_arn("not-an-arn", None), None); + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 85bbefb3e..da5da4f74 100755 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -32,6 +32,9 @@ uuid = { workspace = true } metrics = { workspace = true } anyhow = { workspace = true } bcrypt = { workspace = true } +# IAM tag charset validation needs the exact Unicode general categories +# (\p{L}, \p{N}, \p{Z}); char::is_alphabetic / is_numeric are not those sets. +unicode-properties = "0.1.4" base64 = { workspace = true } time = { workspace = true } aes-gcm = { workspace = true } diff --git a/crates/server/src/authorization.rs b/crates/server/src/authorization.rs index 715f917d5..22b83137a 100755 --- a/crates/server/src/authorization.rs +++ b/crates/server/src/authorization.rs @@ -85,14 +85,15 @@ async fn check_user_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - // Fetch all 5 authz inputs concurrently — they are independent cache + // Fetch all authz inputs concurrently; they are independent cache // lookups (or DB queries, on cold start). All come back pre-parsed. - let (user_policies, group_policies, boundary, principal_tags, resource_tags) = tokio::try_join!( + let (user_policies, group_policies, boundary, principal_tags, resource_tags, created_at) = tokio::try_join!( wrap_policies(cache.fetch_user_policies(account_id, user_name)), wrap_policies(cache.fetch_user_group_policies(account_id, user_name)), wrap_boundary(cache.fetch_user_boundary(account_id, user_name)), wrap_tags(cache.fetch_user_tags(account_id, user_name)), wrap_tags(cache.fetch_resource_tags(resource_arn)), + wrap_created_at(cache.fetch_user_created_at(account_id, user_name)), )?; // Combine identity policies. Each side is an `Arc>>`; @@ -108,6 +109,22 @@ async fn check_user_authorization( // so we deref through the Arc and clone once. This is the only place // where the tag map allocation is unavoidable; future iterations could // change RequestContext to hold Arc> for true zero-copy. + let mut params = params; + params.principal_arn = Some(format!("arn:aws:iam::{account_id}:user/{user_name}")); + params.principal_account = Some(account_id.to_owned()); + params.principal_type = Some("User".to_owned()); + params.username = Some(user_name.to_owned()); + params.userid = Some(extenddb_auth::policy::principal_id::user_unique_id( + account_id, + user_name, + created_at + .as_deref() + .map(String::as_str) + .unwrap_or_default(), + )); + // The target table's owner account. ExtendDB builds the resource ARN with + // the caller's account, so the resource account is that account. + params.resource_account = Some(account_id.to_owned()); let context = RequestContext::build( (*principal_tags).clone(), (*resource_tags).clone(), @@ -153,13 +170,43 @@ async fn check_role_authorization( ) -> Result<(), DynamoDbError> { let action = format!("dynamodb:{operation}"); - let (identity_policies, boundary, (session_policy, principal_tags), resource_tags) = tokio::try_join!( + let ( + identity_policies, + boundary, + (session_policy, principal_tags, token_issue_time), + resource_tags, + role_created_at, + ) = tokio::try_join!( wrap_policies(cache.fetch_role_policies(account_id, role_name)), wrap_boundary(cache.fetch_role_boundary(account_id, role_name)), fetch_session_data_and_tags(cache, account_id, role_name, session_name), wrap_tags(cache.fetch_resource_tags(resource_arn)), + wrap_created_at(cache.fetch_role_created_at(account_id, role_name)), )?; + let mut params = params; + params.principal_arn = Some(format!( + "arn:aws:sts::{account_id}:assumed-role/{role_name}/{session_name}" + )); + params.principal_account = Some(account_id.to_owned()); + // A role session's principal type is AssumedRole; the service does not + // populate aws:username for it, so username stays None (absent). + params.principal_type = Some("AssumedRole".to_owned()); + params.userid = Some(extenddb_auth::policy::principal_id::role_session_userid( + account_id, + role_name, + session_name, + role_created_at + .as_deref() + .map(String::as_str) + .unwrap_or_default(), + )); + params.resource_account = Some(account_id.to_owned()); + // aws:TokenIssueTime for a role session is the session's issue time. + params.token_issue_time = token_issue_time; + // A role session is assumed without MFA, so aws:MultiFactorAuthPresent is + // present and false, matching the service. An IAM user leaves it absent. + params.multi_factor_auth_present = Some(false); let context = RequestContext::build(principal_tags, (*resource_tags).clone(), is_scan, params); // Convert the Arc> to a slice for the evaluator. We materialize @@ -240,6 +287,14 @@ async fn wrap_tags( fut.await.map_err(op_err_to_dynamo) } +async fn wrap_created_at( + fut: impl std::future::Future< + Output = extenddb_storage::management_store::OpResult>>, + >, +) -> Result>, DynamoDbError> { + fut.await.map_err(op_err_to_dynamo) +} + async fn fetch_session_data_and_tags( cache: &CachedAuthzStore, account_id: &str, @@ -249,6 +304,7 @@ async fn fetch_session_data_and_tags( ( Option>, HashMap, + Option, ), DynamoDbError, > { @@ -268,13 +324,50 @@ async fn fetch_session_data_and_tags( // is absent — acceptable; merging into-place is the simplest semantics. let mut tags: HashMap = (*role_tags).clone(); let mut session_policy = None; + let mut token_issue_time = None; if let Some(data) = session_data { session_policy = data.session_policy.clone(); + token_issue_time = data.token_issue_time.clone(); // Merge session tags (session wins on conflict). The session_data // value is shared via Arc; iterate borrowed and clone-on-insert. for (k, v) in &data.session_tags { tags.insert(k.clone(), v.clone()); } } - Ok((session_policy, tags)) + Ok((session_policy, tags, token_issue_time)) +} + +/// The value of `aws:SourceIp` for a request that arrived from `peer`. +/// +/// An IPv4 client that reaches a dual-stack (`::`) listener appears as the +/// IPv4-mapped IPv6 address `::ffff:a.b.c.d`. The service evaluates such a +/// client as its IPv4 address, so an IPv4 CIDR in `IpAddress` or `NotIpAddress` +/// matches it. The mapped form is converted back to IPv4 here; any other +/// address is returned as is. +pub(crate) fn condition_source_ip(peer: &std::net::SocketAddr) -> String { + peer.ip().to_canonical().to_string() +} + +#[cfg(test)] +mod source_ip_tests { + use super::condition_source_ip; + use std::net::SocketAddr; + + #[test] + fn ipv4_mapped_peer_is_evaluated_as_ipv4() { + let mapped: SocketAddr = "[::ffff:127.0.0.1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&mapped), "127.0.0.1"); + let mapped_private: SocketAddr = "[::ffff:10.1.2.3]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&mapped_private), "10.1.2.3"); + } + + #[test] + fn other_peers_are_unchanged() { + let v4: SocketAddr = "10.1.2.3:443".parse().unwrap(); + assert_eq!(condition_source_ip(&v4), "10.1.2.3"); + let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&v6), "2001:db8::1"); + let loopback6: SocketAddr = "[::1]:443".parse().unwrap(); + assert_eq!(condition_source_ip(&loopback6), "::1"); + } } diff --git a/crates/server/src/authz_cache.rs b/crates/server/src/authz_cache.rs index 7997735a9..30017c151 100644 --- a/crates/server/src/authz_cache.rs +++ b/crates/server/src/authz_cache.rs @@ -39,6 +39,9 @@ use futures::future::BoxFuture; pub struct CachedSessionData { pub session_policy: Option>, pub session_tags: HashMap, + /// The session issue time (`aws:TokenIssueTime`), RFC 3339 UTC, or `None` + /// if the stored value could not be read. + pub token_issue_time: Option, } /// Configuration for the authorization cache. Each sub-cache is sized @@ -111,6 +114,11 @@ pub struct CachedAuthzStore { role_tags: SwrCache<(String, String), TagMap, OpError>, session_data: SwrCache<(String, String, String), Arc, OpError>, resource_tags: SwrCache, + /// Principal creation time (RFC 3339), part of the `aws:userid` derivation. + /// Invalidated with the principal's other entries so a delete and recreate + /// resolves a fresh id. + user_created_at: SwrCache<(String, String), Arc, OpError>, + role_created_at: SwrCache<(String, String), Arc, OpError>, } impl CachedAuthzStore { @@ -153,6 +161,9 @@ impl CachedAuthzStore { extenddb_cache::SwrCache::new(loader, cache_cfg) } } + // The principal creation-time caches reuse the per-principal tag cache + // sizing; capture it before the field initializers consume it. + let created_at_cfg = cfg.principal_tags.clone(); Self { user_policies: mk_cache( make_user_policies_loader(inner.clone()), @@ -194,6 +205,22 @@ impl CachedAuthzStore { cfg.session_data, pass_through, ), + user_created_at: mk_cache( + make_user_created_at_loader(inner.clone()), + extenddb_cache::SwrCacheConfig { + name: "user_created_at", + ..created_at_cfg.clone() + }, + pass_through, + ), + role_created_at: mk_cache( + make_role_created_at_loader(inner.clone()), + extenddb_cache::SwrCacheConfig { + name: "role_created_at", + ..created_at_cfg + }, + pass_through, + ), resource_tags: mk_cache( make_resource_tags_loader(inner), cfg.resource_tags, @@ -315,6 +342,30 @@ impl CachedAuthzStore { .unwrap_or_else(|| Arc::new(HashMap::new()))) } + /// Fetch a user's creation time (RFC 3339), part of its `aws:userid`. + /// `None` if the user does not exist. + pub async fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult>> { + self.user_created_at + .get((account_id.to_owned(), user_name.to_owned())) + .await + } + + /// Fetch a role's creation time (RFC 3339), part of its `aws:userid`. + /// `None` if the role does not exist. + pub async fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> OpResult>> { + self.role_created_at + .get((account_id.to_owned(), role_name.to_owned())) + .await + } + // ── Invalidation API ─────────────────────────────────────────────── pub async fn invalidate_user_policies(&self, account_id: &str, user_name: &str) { @@ -360,6 +411,12 @@ impl CachedAuthzStore { .await; } + pub async fn invalidate_user_created_at(&self, account_id: &str, user_name: &str) { + self.user_created_at + .invalidate(&(account_id.to_owned(), user_name.to_owned())) + .await; + } + pub async fn invalidate_role_policies(&self, account_id: &str, role_name: &str) { self.role_policies .invalidate(&(account_id.to_owned(), role_name.to_owned())) @@ -378,6 +435,12 @@ impl CachedAuthzStore { .await; } + pub async fn invalidate_role_created_at(&self, account_id: &str, role_name: &str) { + self.role_created_at + .invalidate(&(account_id.to_owned(), role_name.to_owned())) + .await; + } + pub async fn invalidate_session(&self, account_id: &str, role_name: &str, session_name: &str) { self.session_data .invalidate(&( @@ -452,6 +515,12 @@ impl CachedAuthzStore { if let Err(e) = self.user_tags.invalidate_if(pred_user.clone()) { log_err("user_tags", e); } + if let Err(e) = self.user_created_at.invalidate_if(pred_user.clone()) { + log_err("user_created_at", e); + } + if let Err(e) = self.role_created_at.invalidate_if(pred_user.clone()) { + log_err("role_created_at", e); + } if let Err(e) = self.role_policies.invalidate_if(pred_user.clone()) { log_err("role_policies", e); } @@ -483,6 +552,8 @@ impl CachedAuthzStore { self.role_tags.invalidate_all(); self.session_data.invalidate_all(); self.resource_tags.invalidate_all(); + self.user_created_at.invalidate_all(); + self.role_created_at.invalidate_all(); } /// Snapshot per-sub-cache counters for export to `/auth-cache-metrics`. @@ -500,6 +571,8 @@ impl CachedAuthzStore { role_tags: self.role_tags.metrics().snapshot(), session_data: self.session_data.metrics().snapshot(), resource_tags: self.resource_tags.metrics().snapshot(), + user_created_at: self.user_created_at.metrics().snapshot(), + role_created_at: self.role_created_at.metrics().snapshot(), }) } @@ -525,6 +598,8 @@ pub struct AuthzCacheMetricsSnapshot { pub role_tags: extenddb_cache::SwrMetricsSnapshot, pub session_data: extenddb_cache::SwrMetricsSnapshot, pub resource_tags: extenddb_cache::SwrMetricsSnapshot, + pub user_created_at: extenddb_cache::SwrMetricsSnapshot, + pub role_created_at: extenddb_cache::SwrMetricsSnapshot, } impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { @@ -568,6 +643,14 @@ impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { Box::pin(self.invalidate_user_tags(account_id, user_name)) } + fn invalidate_user_created_at<'a>( + &'a self, + account_id: &'a str, + user_name: &'a str, + ) -> BoxFuture<'a, ()> { + Box::pin(self.invalidate_user_created_at(account_id, user_name)) + } + fn invalidate_role_policies<'a>( &'a self, account_id: &'a str, @@ -592,6 +675,14 @@ impl extenddb_auth::AuthzCacheInvalidator for CachedAuthzStore { Box::pin(self.invalidate_role_tags(account_id, role_name)) } + fn invalidate_role_created_at<'a>( + &'a self, + account_id: &'a str, + role_name: &'a str, + ) -> BoxFuture<'a, ()> { + Box::pin(self.invalidate_role_created_at(account_id, role_name)) + } + fn invalidate_session<'a>( &'a self, account_id: &'a str, @@ -639,6 +730,7 @@ type BoundaryLoader = Loader<(String, String), Arc, OpError>; type TagsLoader = Loader<(String, String), TagMap, OpError>; type SessionLoader = Loader<(String, String, String), Arc, OpError>; type ResourceTagsLoader = Loader; +type CreatedAtLoader = Loader<(String, String), Arc, OpError>; // Policy loaders surface `Ok(None)` when the underlying store returns an // empty Vec — same rationale as the tag loaders below. "Principal exists @@ -779,6 +871,34 @@ fn make_resource_tags_loader(inner: Arc) -> ResourceTags ) } +fn make_user_created_at_loader(inner: Arc) -> CreatedAtLoader { + Arc::new(move |(account_id, user_name): (String, String)| + -> BoxFuture<'static, OpResult>>> { + let inner = inner.clone(); + async move { + Ok(inner + .fetch_user_created_at(&account_id, &user_name) + .await? + .map(Arc::new)) + } + .boxed() + }) +} + +fn make_role_created_at_loader(inner: Arc) -> CreatedAtLoader { + Arc::new(move |(account_id, role_name): (String, String)| + -> BoxFuture<'static, OpResult>>> { + let inner = inner.clone(); + async move { + Ok(inner + .fetch_role_created_at(&account_id, &role_name) + .await? + .map(Arc::new)) + } + .boxed() + }) +} + fn make_session_data_loader(inner: Arc) -> SessionLoader { Arc::new(move |(account_id, role_name, session_name): (String, String, String)| -> BoxFuture<'static, OpResult>>> { @@ -797,6 +917,7 @@ fn make_session_data_loader(inner: Arc) -> SessionLoader Ok(Some(Arc::new(CachedSessionData { session_policy, session_tags, + token_issue_time: data.created_at, }))) } .boxed() @@ -1012,6 +1133,22 @@ mod tests { .unwrap_or_default(); Box::pin(async move { Ok(v) }) } + + fn fetch_user_created_at( + &self, + _account_id: &str, + _user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { Ok(None) }) + } + + fn fetch_role_created_at( + &self, + _account_id: &str, + _role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { Ok(None) }) + } } fn fast_authz_cfg() -> AuthzCacheConfig { @@ -1158,6 +1295,7 @@ mod tests { Some(SessionData { session_policy: Some(allow_policy("dynamodb:Query")), session_tags: vec![("Project".to_owned(), "Atlas".to_owned())], + created_at: None, }), ); let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); @@ -1195,6 +1333,7 @@ mod tests { Some(SessionData { session_policy: None, session_tags: vec![], + created_at: None, }), ); } @@ -1204,6 +1343,7 @@ mod tests { Some(SessionData { session_policy: None, session_tags: vec![], + created_at: None, }), ); let cache = CachedAuthzStore::new(inner.clone(), fast_authz_cfg()); diff --git a/crates/server/src/console/pages/policy_pages.rs b/crates/server/src/console/pages/policy_pages.rs index 46b82d40b..54c3e23d8 100755 --- a/crates/server/src/console/pages/policy_pages.rs +++ b/crates/server/src/console/pages/policy_pages.rs @@ -285,6 +285,28 @@ async fn put_policy( .into_response(); }; + // Apply the same identity-policy validation as the management API, so + // the console cannot store a document the service, and the management + // API, refuse. + let invalid = if doc.get("Version").is_none() || doc.get("Statement").is_none() { + Some("Policy document must contain Version and Statement".to_owned()) + } else { + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&doc.to_string()) + .err() + .map(|e| format!("Invalid policy document: {e}")) + }; + if let Some(msg) = invalid { + let nav = html::nav_bar(&identity_label(&session.identity)); + let content = format!("

Add Policy

{}", html::alert_error(&msg)); + return Html(html::layout_csrf( + "New Policy", + &nav, + &content, + &session.csrf_token, + )) + .into_response(); + } + match state .catalog_store .put_policy( diff --git a/crates/server/src/console/pages/role_pages.rs b/crates/server/src/console/pages/role_pages.rs index cd1963c21..f2ffe4697 100755 --- a/crates/server/src/console/pages/role_pages.rs +++ b/crates/server/src/console/pages/role_pages.rs @@ -115,6 +115,28 @@ pub async fn create_role( .into_response(); }; + // Apply the same trust-policy validation as the management API, so the + // console cannot store a trust policy that the service, and the + // management API, refuse. + let invalid = if tp.get("Statement").is_none() { + Some("Trust policy must contain a Statement".to_owned()) + } else { + crate::management::validate_trust_policy(&tp) + .err() + .map(|msg| format!("Invalid trust policy: {msg}")) + }; + if let Some(msg) = invalid { + let nav = html::nav_bar(&identity_label(&session.identity)); + let content = format!("

Create Role

{}", html::alert_error(&msg)); + return Html(html::layout_csrf( + "New Role", + &nav, + &content, + &session.csrf_token, + )) + .into_response(); + } + match state .catalog_store .create_role(&account_id, &form.role_name, &tp) diff --git a/crates/server/src/handler.rs b/crates/server/src/handler.rs index 5a5a5f51c..7d609c3e0 100755 --- a/crates/server/src/handler.rs +++ b/crates/server/src/handler.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use axum::body::Bytes; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::http::HeaderMap; use axum::response::Response; use extenddb_core::error::DynamoDbError; @@ -32,6 +32,7 @@ use crate::throttle_helpers::{ pub(crate) async fn handle_request( State(state): State>, uri: axum::http::Uri, + ConnectInfo(peer_addr): ConnectInfo, mut headers: HeaderMap, body: Result, ) -> Response { @@ -117,7 +118,25 @@ pub(crate) async fn handle_request( &request_id, ); } - match authorize_request(&state, &identity, &input, &operation, &account_id).await { + // aws:SourceIp is the connection peer address; aws:UserAgent is the + // User-Agent header. Behind a proxy only the peer address is trusted, + // never an X-Forwarded-For header. + let source_ip = crate::authorization::condition_source_ip(&peer_addr); + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); + match authorize_request( + &state, + &identity, + &input, + &operation, + &account_id, + Some(source_ip.as_str()), + user_agent.as_deref(), + ) + .await + { Ok(ki) => pre_fetched_key_info = ki, Err(e) => return error_response(&e, &request_id), } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index b36c04ab2..7a2471de9 100755 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -126,6 +126,7 @@ pub async fn start_server( let metrics_for_console = state.metrics.clone(); let authz_cache_for_mgmt = state.authz_cache.clone(); let table_key_info_cache_for_mgmt = state.table_key_info_cache.clone(); + let mgmt_region = state.region.clone(); let version_info = state.version_info.clone(); let docs_store = state.docs_store.clone(); let listen_url = { @@ -173,6 +174,8 @@ pub async fn start_server( auth_cache: auth_cache_for_mgmt, authz_cache: authz_cache_for_mgmt, table_key_info_cache: table_key_info_cache_for_mgmt, + region: mgmt_region, + secure_transport: tls_enabled, }); let mgmt_router = management::router().with_state(mgmt_state); app = app.nest("/management", mgmt_router); diff --git a/crates/server/src/management/assume_role.rs b/crates/server/src/management/assume_role.rs index af4a41397..0be4b5295 100755 --- a/crates/server/src/management/assume_role.rs +++ b/crates/server/src/management/assume_role.rs @@ -20,7 +20,7 @@ use super::auth::authenticate_admin; use super::crypto::{ encrypt_secret, generate_secret_key, generate_session_key_id, generate_session_token, }; -use extenddb_auth::policy::condition::evaluate_condition; +use extenddb_auth::policy::condition::{ConditionMatch, evaluate_statement_conditions}; use extenddb_auth::policy::context::AssumeRoleContext; use extenddb_auth::policy::document::parse_conditions; @@ -69,7 +69,7 @@ pub async fn assume_role( body: axum::body::Bytes, ) -> Response { // Authenticate via Basic auth — admin only. - let source_ip = addr.ip().to_string(); + let source_ip = crate::authorization::condition_source_ip(&addr); if let Err(e) = authenticate_admin( &headers, &*state.catalog_store, @@ -145,15 +145,55 @@ pub async fn assume_role( } }; - // Evaluate trust policy against the provided caller ARN. + // Session tags passed on the call require sts:TagSession in the trust + // policy, so the STS action set the decision checks depends on their + // presence. They also populate aws:RequestTag/* and aws:TagKeys for + // condition evaluation. + let has_session_tags = request.session_tags.as_ref().is_some_and(value_has_entries); + let request_tags = parse_session_tags(request.session_tags.as_ref()); + + if let Some(st) = request.session_tags.as_ref() { + let pairs = session_tag_pairs(st); + if let Err(e) = super::validate_iam_tags(&pairs) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } + if let Some(dup) = super::duplicate_tag_key_ci(&pairs) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate session tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + } + + // Evaluate trust policy against the provided caller ARN. aws:SourceIp and + // aws:UserAgent are the AssumeRole connection's peer address and User-Agent, + // which is what STS records for the call. + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); if !evaluate_trust_policy( &trust_policy, caller_arn, request.external_id.as_deref(), + &request.session_name, + has_session_tags, + request_tags, + &state.region, + state.secure_transport, + Some(source_ip.as_str()), + user_agent.as_deref(), + &account_id, &*state.catalog_store, ) .await { + tracing::warn!( + target: "extenddb::audit::manage", + "assume-role denied: account={}, role={}, caller={}", + account_id, role_name, caller_arn, + ); return ( StatusCode::FORBIDDEN, "Caller is not authorized to assume this role", @@ -269,78 +309,649 @@ async fn generate_and_store_session( // Trust policy evaluation // --------------------------------------------------------------------------- -/// Evaluate a trust policy to determine if `caller_arn` is allowed to assume -/// the role. +/// Evaluate a trust policy to decide whether `caller_arn` may assume the role, +/// given the external id and whether session tags were passed. /// -/// Checks each Statement for `Effect: Allow` with a matching Principal and -/// passing conditions. Principal matching supports: -/// - `"*"` (any caller) -/// - `{"AWS": "arn:..."}` (single ARN) -/// - `{"AWS": ["arn:...", ...]}` (list of ARNs) +/// Follows IAM order per requested STS action. An `AssumeRole` call exercises +/// `sts:AssumeRole`; passing session tags additionally exercises +/// `sts:TagSession`. ExtendDB does not implement source identity, so +/// `sts:SetSourceIdentity` is never exercised. Assumption is granted only when +/// every exercised action has a matching `Allow` and no matching `Deny`, with +/// an explicit `Deny` winning over any `Allow` (ADR-0022). /// -/// Conditions are evaluated using the policy engine's `AssumeRoleContext`, -/// which supports `aws:PrincipalTag/*` and `sts:ExternalId` keys. +/// A statement matches an action when its `Principal` matches the caller, its +/// `Action`/`NotAction` matches the action, and its `Condition` block passes. A +/// `Deny` whose condition fails to parse fails closed: it blocks, so a +/// malformed stored `Deny` cannot silently grant assumption. Documents are +/// validated at write time (`validate_trust_policy`), so a well-formed store +/// does not reach the fail-closed path in normal operation. +#[allow(clippy::too_many_arguments)] async fn evaluate_trust_policy( trust_policy: &Value, caller_arn: &str, external_id: Option<&str>, + session_name: &str, + has_session_tags: bool, + request_tags: std::collections::HashMap, + region: &str, + secure_transport: bool, + source_ip: Option<&str>, + user_agent: Option<&str>, + account_id: &str, store: &dyn extenddb_storage::CatalogStore, ) -> bool { - let Some(statements) = trust_policy.get("Statement").and_then(|s| s.as_array()) else { + let Some(statements) = statements_vec(trust_policy) else { return false; }; let principal_tags = fetch_caller_tags(caller_arn, store).await; + let caller_created_at = fetch_caller_created_at(caller_arn, store).await; + let (current_time, epoch_time) = extenddb_auth::policy::context::request_time_strings(); let context = AssumeRoleContext { principal_tags, external_id: external_id.map(String::from), + // sts:RoleSessionName is the session name on the AssumeRole call; STS + // populates it for every call. + session_name: Some(session_name.to_owned()), + principal_arn: caller_arn.to_owned(), + principal_account: caller_account(caller_arn).map(ToOwned::to_owned), + request_tags, + region: Some(region.to_owned()), + secure_transport, + principal_type: caller_principal_type(caller_arn).map(ToOwned::to_owned), + username: caller_username(caller_arn).map(ToOwned::to_owned), + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), + // aws:userid of the caller (an AIDA... id for a user, role-id:session + // for a role session), derived from the caller ARN and the caller + // principal's creation time so a recreated principal gets a new id. + userid: extenddb_auth::policy::principal_id::userid_for_caller_arn( + caller_arn, + caller_created_at.as_deref(), + ), + // aws:ResourceAccount is the account that owns the role being assumed. + resource_account: Some(account_id.to_owned()), + current_time, + epoch_time, }; + trust_decision(&statements, caller_arn, &context, has_session_tags) +} + +/// The `aws:PrincipalType` for a caller ARN: `User` for an IAM user ARN, +/// `AssumedRole` for an assumed-role ARN. `None` for any other form, which then +/// fails closed if a trust policy conditions on `aws:PrincipalType`. Measured +/// live: an IAM user caller is `User`, a role session is `AssumedRole`. +fn caller_principal_type(caller_arn: &str) -> Option<&'static str> { + let resource = caller_arn.splitn(6, ':').nth(5)?; + if resource.starts_with("user/") { + Some("User") + } else if resource.starts_with("assumed-role/") { + Some("AssumedRole") + } else { + None + } +} + +/// The `aws:username` for a caller ARN: the user name for an IAM user ARN; +/// `None` otherwise (the service does not populate it for a role session). +fn caller_username(caller_arn: &str) -> Option<&str> { + caller_arn.splitn(6, ':').nth(5)?.strip_prefix("user/") +} + +/// The statements of a policy document as an owned list. `Statement` may be a +/// single object or an array; both are supported, matching IAM. Returns `None` +/// when `Statement` is absent or neither an object nor an array. +fn statements_vec(trust_policy: &Value) -> Option> { + match trust_policy.get("Statement") { + Some(Value::Array(arr)) => Some(arr.clone()), + Some(obj @ Value::Object(_)) => Some(vec![obj.clone()]), + _ => None, + } +} + +/// Decide assumption from resolved statements and context. Split out so the +/// per-action Deny-over-Allow ordering is unit-testable without a store. +fn trust_decision( + statements: &[Value], + caller_arn: &str, + context: &AssumeRoleContext, + has_session_tags: bool, +) -> bool { + requested_actions(has_session_tags) + .iter() + .all(|action| action_permitted(statements, caller_arn, context, action)) +} + +/// The STS actions an `AssumeRole` request exercises. +fn requested_actions(has_session_tags: bool) -> Vec<&'static str> { + let mut actions = vec!["sts:AssumeRole"]; + if has_session_tags { + actions.push("sts:TagSession"); + } + actions +} + +/// Whether a single STS action is permitted: a matching explicit `Deny` blocks +/// it; otherwise a matching `Allow` grants it; with neither it is implicitly +/// denied. +fn action_permitted( + statements: &[Value], + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { for stmt in statements { - let effect = stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or(""); - if effect != "Allow" { - continue; + if effect_of(stmt).eq_ignore_ascii_case("Deny") + && deny_statement_blocks(stmt, caller_arn, context, action) + { + return false; } - - let principal = &stmt["Principal"]; - if !principal_matches(principal, caller_arn) { - continue; + } + for stmt in statements { + if effect_of(stmt).eq_ignore_ascii_case("Allow") + && allow_statement_grants(stmt, caller_arn, context, action) + { + return true; } + } + false +} - let conditions_pass = match parse_conditions(&stmt["Condition"]) { - Ok(conds) => conds.iter().all(|c| evaluate_condition(c, &context)), - Err(_) => false, - }; +/// The `Effect` string of a statement, or `""` when absent/non-string. Compared +/// case-insensitively by the caller, matching the identity-policy parser. +fn effect_of(stmt: &Value) -> &str { + stmt.get("Effect").and_then(|e| e.as_str()).unwrap_or("") +} + +/// Whether a `Deny` blocks the action: its principal and action match and its +/// condition passes. A condition that fails to parse fails closed (blocks). +fn deny_statement_blocks( + stmt: &Value, + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { + if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { + return false; + } + matches!( + evaluate_conditions(stmt, context), + ConditionOutcome::Pass | ConditionOutcome::Unparseable + ) +} + +/// Whether an `Allow` grants the action: principal, action and condition all +/// match. A condition that fails to parse does not grant. +fn allow_statement_grants( + stmt: &Value, + caller_arn: &str, + context: &AssumeRoleContext, + action: &str, +) -> bool { + if !principal_matches(&stmt["Principal"], caller_arn) || !action_matches(stmt, action) { + return false; + } + matches!(evaluate_conditions(stmt, context), ConditionOutcome::Pass) +} + +/// Outcome of a statement's `Condition` block for a given context. +enum ConditionOutcome { + Pass, + Fail, + Unparseable, +} + +/// Evaluate a statement's `Condition` block. Scalar condition values (numbers, +/// booleans) are coerced to strings, matching IAM. An unknown operator or a +/// structurally invalid block is reported as `Unparseable`. +fn evaluate_conditions(stmt: &Value, context: &AssumeRoleContext) -> ConditionOutcome { + let normalized = normalize_condition_scalars(&stmt["Condition"]); + match parse_conditions(&normalized) { + Ok(conds) => match evaluate_statement_conditions(&conds, context) { + ConditionMatch::Match => ConditionOutcome::Pass, + ConditionMatch::NoMatch => ConditionOutcome::Fail, + // A condition on a key STS populates but ExtendDB cannot resolve + // here fails closed, the same as an operator the evaluator cannot + // parse: a Deny blocks, an Allow does not grant. + ConditionMatch::Unresolvable => ConditionOutcome::Unparseable, + }, + Err(_) => ConditionOutcome::Unparseable, + } +} + +/// Whether a statement's `Action`/`NotAction` covers `action`, case-insensitive +/// and with `*` and prefix wildcards. A statement with neither field covers no +/// action. +fn action_matches(stmt: &Value, action: &str) -> bool { + if let Some(actions) = action_list(stmt.get("Action")) { + actions.iter().any(|p| action_pattern_matches(p, action)) + } else if let Some(not_actions) = action_list(stmt.get("NotAction")) { + !not_actions + .iter() + .any(|p| action_pattern_matches(p, action)) + } else { + false + } +} + +/// A statement's action field as a list of patterns, or `None` when absent. +fn action_list(v: Option<&Value>) -> Option> { + match v { + Some(Value::String(s)) => Some(vec![s.clone()]), + Some(Value::Array(arr)) => Some( + arr.iter() + .filter_map(|a| a.as_str().map(ToOwned::to_owned)) + .collect(), + ), + _ => None, + } +} + +/// Match an action pattern against an action, case-insensitive. Supports `*` +/// (any action) and a trailing-`*` prefix wildcard such as `sts:*`. +fn action_pattern_matches(pattern: &str, action: &str) -> bool { + let p = pattern.to_ascii_lowercase(); + let a = action.to_ascii_lowercase(); + if p == "*" { + return true; + } + if let Some(prefix) = p.strip_suffix('*') { + return a.starts_with(prefix); + } + p == a +} - if conditions_pass { +/// Whether a Principal field matches the caller ARN. Supports `"*"`, `{"AWS": +/// ""|"*"|""|""}` and lists thereof. +/// Account-id and account-root principals match any caller in that account. +/// Objects without an `AWS` key (for example `Service` principals) never match +/// an IAM user or assumed-role caller. +fn principal_matches(principal: &Value, caller_arn: &str) -> bool { + match principal { + Value::String(s) => s == "*", + Value::Object(map) => match map.get("AWS") { + Some(Value::String(arn)) => principal_entry_matches(arn, caller_arn), + Some(Value::Array(arns)) => arns + .iter() + .filter_map(Value::as_str) + .any(|a| principal_entry_matches(a, caller_arn)), + _ => false, + }, + _ => false, + } +} + +/// Whether one `AWS` principal entry matches the caller. Exact ARN, `"*"`, bare +/// account id, and account-root ARN (`arn:aws:iam:::root`) all match. +fn principal_entry_matches(entry: &str, caller_arn: &str) -> bool { + if entry == "*" || entry == caller_arn { + return true; + } + if let Some(account) = caller_account(caller_arn) { + if entry == account { + return true; + } + if let Some(root_account) = entry + .strip_prefix("arn:aws:iam::") + .and_then(|r| r.strip_suffix(":root")) + && root_account == account + { return true; } } - false } -/// Check if a Principal field matches the given caller ARN. -fn principal_matches(principal: &Value, caller_arn: &str) -> bool { +/// The account id field of an ARN (`arn:partition:service:region:account:...`). +fn caller_account(caller_arn: &str) -> Option<&str> { + caller_arn.split(':').nth(4).filter(|s| !s.is_empty()) +} + +/// Whether a JSON value is a non-empty object or array. +fn value_has_entries(v: &Value) -> bool { + match v { + Value::Object(o) => !o.is_empty(), + Value::Array(a) => !a.is_empty(), + _ => false, + } +} + +/// Extract session-tag `(key, value)` pairs preserving every entry (including +/// keys that collide by ASCII case), for write validation. Object and list +/// forms; scalar values coerced to strings. +fn session_tag_pairs(session_tags: &Value) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + match session_tags { + Value::Object(obj) => { + for (k, v) in obj { + if let Some(s) = scalar_to_string(v) { + pairs.push((k.clone(), s)); + } + } + } + Value::Array(arr) => { + for entry in arr { + let key = entry.get("Key").and_then(Value::as_str); + let value = entry.get("Value").and_then(scalar_to_string); + if let (Some(k), Some(v)) = (key, value) { + pairs.push((k.to_owned(), v)); + } + } + } + _ => {} + } + pairs +} + +/// Parse the request `session_tags` into a key/value map for condition +/// evaluation (`aws:RequestTag/*`, `aws:TagKeys`). Accepts the object form +/// `{"env": "prod"}` and the list form `[{"Key": "env", "Value": "prod"}]`. +/// Scalar values are coerced to their string form; non-scalar values are +/// skipped. +fn parse_session_tags(session_tags: Option<&Value>) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + match session_tags { + Some(Value::Object(obj)) => { + for (k, v) in obj { + if let Some(s) = scalar_to_string(v) { + map.insert(k.clone(), s); + } + } + } + Some(Value::Array(arr)) => { + for entry in arr { + let key = entry.get("Key").and_then(Value::as_str); + let value = entry.get("Value").and_then(scalar_to_string); + if let (Some(k), Some(v)) = (key, value) { + map.insert(k.to_owned(), v); + } + } + } + _ => {} + } + map +} + +/// A JSON scalar (string, number, bool) as its string form; `None` otherwise. +fn scalar_to_string(v: &Value) -> Option { + match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// Coerce scalar condition values (numbers, booleans) to strings, matching +/// IAM's handling of condition values, so a numeric or boolean value parses and +/// evaluates as its string form rather than being rejected. +fn normalize_condition_scalars(v: &Value) -> Value { + match v { + Value::Number(n) => Value::String(n.to_string()), + Value::Bool(b) => Value::String(b.to_string()), + Value::Array(a) => Value::Array(a.iter().map(normalize_condition_scalars).collect()), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, val)| (k.clone(), normalize_condition_scalars(val))) + .collect(), + ), + other => other.clone(), + } +} + +// --------------------------------------------------------------------------- +// Trust policy write-time validation +// --------------------------------------------------------------------------- + +/// Allowed actions in an `AssumeRole` trust policy, lowercase for comparison. +const STS_TRUST_ACTIONS: [&str; 3] = ["sts:assumerole", "sts:tagsession", "sts:setsourceidentity"]; + +/// Validate a trust policy document at write time, rejecting the documents IAM +/// rejects for an `AssumeRole` trust policy and accepting those it accepts. The +/// error text is ExtendDB's own; the accepted and rejected sets match IAM. +/// +/// Rejects: missing or non-string `Effect` and values other than Allow/Deny; +/// `NotPrincipal`; a missing `Principal`; a bare string principal (including +/// `"*"`); an `AWS` principal ARN containing a wildcard; a missing +/// `Action`/`NotAction`; an action outside the STS trust set; a +/// `Resource`/`NotResource` field; and a `Condition` with an unknown operator +/// or invalid structure. Accepts a missing `Version`, a single-object +/// `Statement`, numeric condition values and unknown condition keys. +pub(crate) fn validate_trust_policy(trust_policy: &Value) -> Result<(), String> { + let Some(statements) = statements_vec(trust_policy) else { + return Err("Trust policy must contain a Statement object or array".to_owned()); + }; + if statements.is_empty() { + return Err("Trust policy Statement must not be empty".to_owned()); + } + // The live service does not store a trust policy that contains a character + // above U+00FF anywhere in the document (a key name, a value, a Sid, or a + // tag key): CreateRole returns InternalFailure (MalformedPolicyDocument for + // a Sid). Latin-1 characters (up to U+00FF, including the sharp s and + // accented letters) are stored, though a non-ASCII condition key name or + // tag key then reads as absent at evaluation (the trust path matches + // ASCII-case only). Reject a document with a character above U+00FF to + // match what the service will store. + if json_has_char_above_latin1(trust_policy) { + return Err("Trust policy must not contain a character above U+00FF".to_owned()); + } + // Measured on live IAM CreateRole: a Sid is accepted only when it is empty + // or entirely ASCII letters and digits; a hyphen, underscore, space, dot or + // non-ASCII character is rejected with MalformedPolicyDocument. Identity + // PutUserPolicy applies the same rule (see validate_statement_sids). + for stmt in &statements { + if let Some(sid) = stmt.get("Sid").and_then(serde_json::Value::as_str) + && !sid.chars().all(|c| c.is_ascii_alphanumeric()) + { + return Err("Trust policy Sid must be ASCII letters and digits only".to_owned()); + } + } + for stmt in &statements { + validate_trust_statement(stmt)?; + } + Ok(()) +} + +/// Whether any object key or string value in `v` contains a code point above +/// U+00FF. The live service will not store a trust policy with one. +fn json_has_char_above_latin1(v: &Value) -> bool { + match v { + Value::String(s) => s.chars().any(|c| c as u32 > 0xFF), + Value::Array(a) => a.iter().any(json_has_char_above_latin1), + Value::Object(o) => o + .iter() + .any(|(k, val)| k.chars().any(|c| c as u32 > 0xFF) || json_has_char_above_latin1(val)), + _ => false, + } +} + +/// A statement field, treating JSON null as absent. +fn non_null(v: Option<&Value>) -> Option<&Value> { + v.filter(|v| !v.is_null()) +} + +fn validate_trust_statement(stmt: &Value) -> Result<(), String> { + match stmt.get("Effect").and_then(Value::as_str) { + Some(e) if e.eq_ignore_ascii_case("Allow") || e.eq_ignore_ascii_case("Deny") => {} + Some(e) => return Err(format!("invalid Effect: {e}")), + None => return Err("statement is missing Effect".to_owned()), + } + + if non_null(stmt.get("NotPrincipal")).is_some() { + return Err("NotPrincipal is not allowed in a trust policy".to_owned()); + } + match non_null(stmt.get("Principal")) { + None => return Err("statement is missing Principal".to_owned()), + Some(p) => validate_trust_principal(p)?, + } + + match ( + non_null(stmt.get("Action")), + non_null(stmt.get("NotAction")), + ) { + (None, None) => return Err("statement is missing Action".to_owned()), + (Some(_), Some(_)) => return Err("statement has both Action and NotAction".to_owned()), + (Some(a), None) | (None, Some(a)) => validate_trust_actions(a)?, + } + + if non_null(stmt.get("Resource")).is_some() || non_null(stmt.get("NotResource")).is_some() { + return Err("Resource is not allowed in a trust policy".to_owned()); + } + + if let Some(cond) = non_null(stmt.get("Condition")) { + validate_trust_condition(cond)?; + } + + Ok(()) +} + +/// Validate a `Condition` block at write time. Accepts the full IAM operator +/// vocabulary (including operators ExtendDB's evaluator does not implement, such +/// as `IpAddress`; a `Deny` using one of those fails closed at evaluation). +/// Rejects an invalid operator name, a non-object block, and a non-object +/// operator entry. Accepts string, number and boolean values and unknown +/// condition keys, matching IAM. +fn validate_trust_condition(cond: &Value) -> Result<(), String> { + let obj = cond + .as_object() + .ok_or_else(|| "Condition must be an object".to_owned())?; + for (op_name, keys) in obj { + if !is_known_iam_condition_operator(op_name) { + return Err(format!("invalid Condition operator: {op_name}")); + } + let keys_obj = keys + .as_object() + .ok_or_else(|| format!("Condition {op_name} must be an object"))?; + for vals in keys_obj.values() { + validate_condition_values(vals)?; + } + } + Ok(()) +} + +fn validate_condition_values(v: &Value) -> Result<(), String> { + let is_scalar = |x: &Value| x.is_string() || x.is_number() || x.is_boolean(); + match v { + _ if is_scalar(v) => Ok(()), + Value::Array(arr) if arr.iter().all(is_scalar) => Ok(()), + _ => Err("condition value must be a string, number, boolean or array of those".to_owned()), + } +} + +/// Whether `name` is a known IAM condition operator (base name, after stripping +/// a `ForAllValues:`/`ForAnyValue:` prefix and an `IfExists` suffix). Used only +/// by write-time validation so the accepted operator set matches IAM even for +/// operators ExtendDB does not evaluate. +fn is_known_iam_condition_operator(name: &str) -> bool { + let base = name + .strip_prefix("ForAllValues:") + .or_else(|| name.strip_prefix("ForAnyValue:")) + .unwrap_or(name); + let base = base.strip_suffix("IfExists").unwrap_or(base); + matches!( + base, + "StringEquals" + | "StringNotEquals" + | "StringEqualsIgnoreCase" + | "StringNotEqualsIgnoreCase" + | "StringLike" + | "StringNotLike" + | "NumericEquals" + | "NumericNotEquals" + | "NumericLessThan" + | "NumericLessThanEquals" + | "NumericGreaterThan" + | "NumericGreaterThanEquals" + | "DateEquals" + | "DateNotEquals" + | "DateLessThan" + | "DateLessThanEquals" + | "DateGreaterThan" + | "DateGreaterThanEquals" + | "Bool" + | "BinaryEquals" + | "IpAddress" + | "NotIpAddress" + | "ArnEquals" + | "ArnLike" + | "ArnNotEquals" + | "ArnNotLike" + | "Null" + ) +} + +fn validate_trust_principal(principal: &Value) -> Result<(), String> { match principal { - // "*" matches any caller. - Value::String(s) if s == "*" => true, - // {"AWS": "arn:..."} or {"AWS": ["arn:...", ...]} + Value::String(_) => { + Err("Principal must be an object such as {\"AWS\": \"\"}".to_owned()) + } Value::Object(map) => { - if let Some(aws) = map.get("AWS") { - match aws { - Value::String(arn) => arn == "*" || arn == caller_arn, - Value::Array(arns) => arns - .iter() - .any(|a| a.as_str().is_some_and(|s| s == "*" || s == caller_arn)), - _ => false, + if map.is_empty() { + return Err("Principal object must not be empty".to_owned()); + } + if let Some(aws) = non_null(map.get("AWS")) { + for entry in principal_entry_strings(aws)? { + validate_aws_principal_entry(&entry)?; } - } else { - false } + Ok(()) + } + _ => Err("invalid Principal value".to_owned()), + } +} + +fn principal_entry_strings(v: &Value) -> Result, String> { + match v { + Value::String(s) => Ok(vec![s.clone()]), + Value::Array(arr) => arr + .iter() + .map(|a| { + a.as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "principal entry must be a string".to_owned()) + }) + .collect(), + _ => Err("principal value must be a string or array of strings".to_owned()), + } +} + +fn validate_aws_principal_entry(entry: &str) -> Result<(), String> { + if entry == "*" { + return Ok(()); + } + if entry.contains('*') { + return Err(format!( + "wildcard is not allowed in a principal ARN: {entry}" + )); + } + Ok(()) +} + +fn validate_trust_actions(actions: &Value) -> Result<(), String> { + let list = match actions { + Value::String(s) => vec![s.clone()], + Value::Array(arr) => arr + .iter() + .map(|a| { + a.as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "Action entry must be a string".to_owned()) + }) + .collect::, _>>()?, + _ => return Err("Action must be a string or array of strings".to_owned()), + }; + if list.is_empty() { + return Err("Action must not be empty".to_owned()); + } + for a in &list { + if !STS_TRUST_ACTIONS.contains(&a.to_ascii_lowercase().as_str()) { + return Err(format!( + "trust policy Action must be one of sts:AssumeRole, sts:TagSession, sts:SetSourceIdentity: {a}" + )); } - _ => false, } + Ok(()) } // --------------------------------------------------------------------------- @@ -371,3 +982,933 @@ async fn fetch_caller_tags( } } } + +/// Resolve the caller principal's creation time (RFC 3339), part of its +/// `aws:userid`. A user ARN resolves the user's creation time, an assumed-role +/// ARN the role's. `None` when the caller is not a local principal (an external +/// ARN), where `aws:userid` is then derived without it. +async fn fetch_caller_created_at( + caller_arn: &str, + store: &dyn extenddb_storage::CatalogStore, +) -> Option { + let parts: Vec<&str> = caller_arn.splitn(6, ':').collect(); + if parts.len() < 6 { + return None; + } + let account_id = parts[4]; + let resource = parts[5]; + let result = if let Some(user_name) = resource.strip_prefix("user/") { + store.fetch_user_created_at(account_id, user_name).await + } else if let Some(rest) = resource.strip_prefix("assumed-role/") { + let role = rest.split('/').next().unwrap_or(""); + store.fetch_role_created_at(account_id, role).await + } else { + Ok(None) + }; + match result { + Ok(v) => v, + Err(e) => { + tracing::error!("fetch_caller_created_at failed for {caller_arn}: {e:?}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + const ACCT: &str = "123456789012"; + const CALLER: &str = "arn:aws:iam::123456789012:user/alice"; + const BOB: &str = "arn:aws:iam::123456789012:user/bob"; + + fn mk_ctx( + principal_tags: HashMap, + external_id: Option, + request_tags: HashMap, + ) -> AssumeRoleContext { + AssumeRoleContext { + principal_tags, + external_id, + session_name: Some("bb-session".to_owned()), + principal_arn: CALLER.to_owned(), + principal_account: Some(ACCT.to_owned()), + request_tags, + region: Some("us-east-1".to_owned()), + secure_transport: true, + principal_type: Some("User".to_owned()), + username: Some("alice".to_owned()), + source_ip: Some("203.0.113.9".to_owned()), + user_agent: Some("aws-cli/2.0 Botocore/1.0".to_owned()), + userid: Some("AIDAEXAMPLEALICEUSERID".to_owned()), + resource_account: Some(ACCT.to_owned()), + current_time: "2026-01-01T00:00:00Z".to_owned(), + epoch_time: "1767225600".to_owned(), + } + } + + fn ctx() -> AssumeRoleContext { + mk_ctx(HashMap::new(), None, HashMap::new()) + } + + fn ctx_ext(id: &str) -> AssumeRoleContext { + mk_ctx(HashMap::new(), Some(id.to_owned()), HashMap::new()) + } + + fn ctx_with_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { + mk_ctx( + tags.iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), + None, + HashMap::new(), + ) + } + + fn ctx_with_request_tags(tags: &[(&str, &str)]) -> AssumeRoleContext { + mk_ctx( + HashMap::new(), + None, + tags.iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), + ) + } + + /// Allow alice to assume (sts:AssumeRole). + fn allow_alice() -> Value { + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}) + } + + // ---- principal_matches ------------------------------------------------ + + #[test] + fn principal_wildcard_string_matches_any() { + assert!(principal_matches(&json!("*"), CALLER)); + } + + #[test] + fn principal_aws_single_arn_matches_exact() { + assert!(principal_matches(&json!({"AWS": CALLER}), CALLER)); + assert!(!principal_matches(&json!({"AWS": BOB}), CALLER)); + } + + #[test] + fn principal_aws_wildcard_matches_any() { + assert!(principal_matches(&json!({"AWS": "*"}), CALLER)); + } + + #[test] + fn principal_aws_list_matches_any_member() { + assert!(principal_matches(&json!({"AWS": [BOB, CALLER]}), CALLER)); + assert!(!principal_matches(&json!({"AWS": [BOB]}), CALLER)); + } + + #[test] + fn principal_account_root_matches_any_caller_in_account() { + let root = format!("arn:aws:iam::{ACCT}:root"); + assert!(principal_matches(&json!({"AWS": root}), CALLER)); + let other_root = "arn:aws:iam::999999999999:root"; + assert!(!principal_matches(&json!({"AWS": other_root}), CALLER)); + } + + #[test] + fn principal_bare_account_id_matches_any_caller_in_account() { + assert!(principal_matches(&json!({"AWS": ACCT}), CALLER)); + assert!(!principal_matches(&json!({"AWS": "999999999999"}), CALLER)); + } + + #[test] + fn principal_missing_aws_does_not_match() { + assert!(!principal_matches( + &json!({"Service": "ec2.amazonaws.com"}), + CALLER + )); + assert!(!principal_matches(&json!(null), CALLER)); + } + + // ---- effect_of (case-insensitive compare at call sites) --------------- + + #[test] + fn effect_of_reads_string_or_defaults_empty() { + assert_eq!(effect_of(&json!({"Effect": "Allow"})), "Allow"); + assert_eq!(effect_of(&json!({"Effect": "Deny"})), "Deny"); + assert_eq!(effect_of(&json!({})), ""); + assert_eq!(effect_of(&json!({"Effect": 5})), ""); + } + + #[test] + fn effect_lowercase_deny_blocks() { + // IAM accepts and enforces a lowercase "deny". + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- action_matches --------------------------------------------------- + + #[test] + fn action_matches_exact_and_wildcards() { + let a = json!({"Action": "sts:AssumeRole"}); + assert!(action_matches(&a, "sts:AssumeRole")); + assert!(!action_matches(&a, "sts:TagSession")); + let star = json!({"Action": "*"}); + assert!(action_matches(&star, "sts:AssumeRole")); + let prefix = json!({"Action": "sts:*"}); + assert!(action_matches(&prefix, "sts:AssumeRole")); + let list = json!({"Action": ["sts:AssumeRole", "sts:TagSession"]}); + assert!(action_matches(&list, "sts:TagSession")); + let none = json!({"Principal": "*"}); + assert!(!action_matches(&none, "sts:AssumeRole")); + } + + #[test] + fn action_matches_not_action() { + let na = json!({"NotAction": "sts:TagSession"}); + assert!(action_matches(&na, "sts:AssumeRole")); + assert!(!action_matches(&na, "sts:TagSession")); + } + + // ---- trust_decision: Deny-over-Allow core ---------------------------- + + #[test] + fn allow_grants_when_principal_matches() { + assert!(trust_decision(&[allow_alice()], CALLER, &ctx(), false)); + } + + #[test] + fn no_matching_allow_is_implicit_deny() { + let stmts = + vec![json!({"Effect": "Allow", "Principal": {"AWS": BOB}, "Action": "sts:AssumeRole"})]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + /// The ticket regression: an explicit Deny must override a broad Allow. + #[test] + fn explicit_deny_overrides_allow_regardless_of_order() { + let allow_then_deny = vec![ + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&allow_then_deny, CALLER, &ctx(), false)); + + let deny_then_allow = vec![ + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}), + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&deny_then_allow, CALLER, &ctx(), false)); + } + + #[test] + fn deny_targeting_other_principal_does_not_block() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({"Effect": "Deny", "Principal": {"AWS": BOB}, "Action": "sts:AssumeRole"}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_on_account_root_blocks_caller() { + let root = format!("arn:aws:iam::{ACCT}:root"); + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": root}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_on_bare_account_id_blocks_caller() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": ACCT}, "Action": "sts:AssumeRole"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- conditional Deny ------------------------------------------------- + + #[test] + fn conditional_deny_only_blocks_when_tag_matches() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": "contractors"}} + }), + ]; + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_tags(&[("team", "contractors")]), + false + )); + assert!(trust_decision( + &stmts, + CALLER, + &ctx_with_tags(&[("team", "employees")]), + false + )); + } + + #[test] + fn external_id_notequals_deny_blocks_when_external_id_absent() { + // Confused-deputy guard: Deny unless the caller presents the external + // id. With no external id the negated condition holds, so Deny blocks. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"sts:ExternalId": "secret"}} + }), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + assert!(!trust_decision(&stmts, CALLER, &ctx_ext("wrong"), false)); + assert!(trust_decision(&stmts, CALLER, &ctx_ext("secret"), false)); + } + + #[test] + fn string_not_equals_ignore_case_external_id() { + // Deny alice unless the external id equals "secret" case-insensitively. + // With a matching id (any case) the negated condition is false, so the + // Deny does not apply and the Allow grants. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringNotEqualsIgnoreCase": {"sts:ExternalId": "secret"}} + }), + ]; + // Present and equal (mixed case): condition false, Deny inert, allowed. + assert!(trust_decision(&stmts, CALLER, &ctx_ext("SECRET"), false)); + assert!(trust_decision(&stmts, CALLER, &ctx_ext("secret"), false)); + // Present and different: condition true, Deny applies, blocked. + assert!(!trust_decision(&stmts, CALLER, &ctx_ext("other"), false)); + // Absent: a negated operator on an absent single-valued key matches, so + // the Deny applies and blocks (confused-deputy guard). + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- fail closed on an unparseable Deny ------------------------------- + #[test] + fn deny_with_unparseable_condition_fails_closed() { + // BinaryEquals is a valid IAM operator the evaluator does not implement, + // so a Deny using it must not be silently dropped. + let stmts = vec![ + allow_alice(), + json!({ + "Effect": "Deny", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} + }), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_with_unparseable_condition_does_not_grant() { + let stmts = vec![json!({ + "Effect": "Allow", + "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} + })]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- resolved SourceIp / UserAgent on the trust path ------------------ + #[test] + fn trust_ipaddress_source_ip_matches_caller() { + // The caller address is 203.0.113.9 (mk_ctx). IpAddress on the enclosing + // range grants; a range the caller is not in does not. Matches live STS. + let in_range = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}})]; + assert!(trust_decision(&in_range, CALLER, &ctx(), false)); + let out_of_range = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"IpAddress": {"aws:SourceIp": "198.51.100.0/24"}}})]; + assert!(!trust_decision(&out_of_range, CALLER, &ctx(), false)); + } + + #[test] + fn trust_notipaddress_deny_blocks_caller_outside_range() { + // Deny unless the caller is inside 203.0.113.0/24. The caller is inside, + // so NotIpAddress is false and the Deny does not apply. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"NotIpAddress": {"aws:SourceIp": "203.0.113.0/24"}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_useragent_stringlike_grants() { + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringLike": {"aws:UserAgent": "*Botocore*"}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_userid_resolves_from_caller_arn() { + // The caller ARN is an IAM user, so aws:userid is the derived AIDA id. + let ctx = ctx(); + let expected = ctx.userid.clone().expect("userid set in mk_ctx"); + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:userid": expected}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx, false)); + } + + #[test] + fn trust_rolesessionname_resolves_from_session_name() { + // mk_ctx sets session_name = "bb-session". A Deny gated on the matching + // session name blocks; a non-matching name does not. An Allow gated on + // the matching name grants. + let ctx = ctx(); + let deny_match = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "bb-session"}}}), + ]; + assert!(!trust_decision(&deny_match, CALLER, &ctx, false)); + let deny_other = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "other"}}}), + ]; + assert!(trust_decision(&deny_other, CALLER, &ctx, false)); + let allow_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:RoleSessionName": "bb-session"}}})]; + assert!(trust_decision(&allow_gate, CALLER, &ctx, false)); + // Case-folded key name resolves too. + let allow_folded = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"sts:rolesessionname": "bb-session"}}})]; + assert!(trust_decision(&allow_folded, CALLER, &ctx, false)); + } + + // ---- resolved keys: PrincipalArn / PrincipalAccount ------------------- + + #[test] + fn deny_principalarn_eq_self_blocks() { + // x04: Deny {"AWS":"*"} if StringEquals aws:PrincipalArn = caller. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalArn": CALLER}}}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_principalarn_notequals_self_does_not_block() { + // x07: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalArn = caller. + // The caller IS alice, so the negated condition is false and the Deny + // does not apply; the Allow grants. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalArn": CALLER}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_principalarn_grants() { + // x01: Allow {"AWS":"*"} if StringEquals aws:PrincipalArn = caller. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalArn": CALLER}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_principalaccount_grants() { + // x08: Allow {"AWS":"*"} if StringEquals aws:PrincipalAccount = acct. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalAccount": ACCT}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_principalaccount_notequals_in_account_does_not_block() { + // x09: Deny {"AWS":"*"} if StringNotEquals aws:PrincipalAccount = acct. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalAccount": ACCT}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- resolved keys: RequestTag / TagKeys (session tags) --------------- + + #[test] + fn deny_requesttag_match_blocks_tagged_call() { + // x17: session tag env=prod, Deny if StringEquals aws:RequestTag/env. + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"StringEquals": {"aws:RequestTag/env": "prod"}}}), + ]; + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "prod")]), + true + )); + } + + #[test] + fn allow_gate_requesttag_grants_tagged_call() { + // x18: session tag env=dev, Allow if StringEquals aws:RequestTag/env. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"StringEquals": {"aws:RequestTag/env": "dev"}}})]; + assert!(trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "dev")]), + true + )); + } + + #[test] + fn deny_tagkeys_foranyvalue_blocks_tagged_call() { + // x19: session tag env=prod, Deny if ForAnyValue:StringEquals + // aws:TagKeys = env. + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Condition": {"ForAnyValue:StringEquals": {"aws:TagKeys": "env"}}}), + ]; + assert!(!trust_decision( + &stmts, + CALLER, + &ctx_with_request_tags(&[("env", "prod")]), + true + )); + } + + // ---- organization keys: absent for a standalone account (R1) ---------- + + #[test] + fn deny_on_absent_org_key_does_not_block() { + // Deny {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. An ExtendDB + // account is a standalone AWS account, so the organization key is absent; + // StringEquals on an absent single-valued key does not match, so the Deny + // does not apply and the Allow grants. Matches IAM for a standalone + // account. + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_negated_on_absent_org_key_blocks() { + // Deny {"AWS":"*"} if StringNotEquals aws:PrincipalOrgID = org. The key + // is absent for a standalone account, and a negated operator on an absent + // single-valued key matches, so the Deny applies and blocks. Matches IAM + // for a standalone account (the key is genuinely absent, not fail-closed). + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-abc123"}}}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_gate_on_absent_org_key_does_not_grant() { + // Allow {"AWS":"*"} if StringEquals aws:PrincipalOrgID = org. The key is + // absent, so the positive operator does not match and the Allow does not + // grant: implicit deny. Matches IAM for a standalone account. + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-abc123"}}})]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + // ---- Action and session tags ----------------------------------------- + + #[test] + fn deny_tagsession_only_does_not_block_plain_assume() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn deny_setsourceidentity_only_does_not_block_plain_assume() { + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:SetSourceIdentity"}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn allow_tagsession_only_does_not_grant_plain_assume() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn session_tags_require_tagsession_allow() { + // Allow covers only sts:AssumeRole; passing session tags needs + // sts:TagSession, which no Allow grants, so the call is denied. + assert!(!trust_decision(&[allow_alice()], CALLER, &ctx(), true)); + // Allow covers both actions: granted. + let both = vec![json!({ + "Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession"] + })]; + assert!(trust_decision(&both, CALLER, &ctx(), true)); + } + + #[test] + fn deny_tagsession_blocks_tagged_call_but_allows_plain() { + let stmts = vec![ + json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": ["sts:AssumeRole", "sts:TagSession"]}), + json!({"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:TagSession"}), + ]; + assert!(!trust_decision(&stmts, CALLER, &ctx(), true)); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn empty_statement_list_denies() { + assert!(!trust_decision(&[], CALLER, &ctx(), false)); + } + + // ---- statements_vec: single-object Statement -------------------------- + + #[test] + fn single_object_statement_is_one_statement() { + let doc = json!({"Version": "2012-10-17", "Statement": { + "Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole" + }}); + let stmts = statements_vec(&doc).expect("single object Statement"); + assert_eq!(stmts.len(), 1); + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn missing_statement_is_none() { + assert!(statements_vec(&json!({"Version": "2012-10-17"})).is_none()); + } + + // ---- validate_trust_policy: accept set -------------------------------- + + fn ok(doc: Value) { + assert!(validate_trust_policy(&doc).is_ok(), "should accept: {doc}"); + } + + fn rejected(doc: Value) { + assert!(validate_trust_policy(&doc).is_err(), "should reject: {doc}"); + } + + #[test] + fn validate_accepts_well_formed() { + ok(json!({"Version": "2012-10-17", "Statement": [allow_alice()]})); + } + + #[test] + fn validate_accepts_missing_version() { + ok(json!({"Statement": [allow_alice()]})); + } + + #[test] + fn validate_accepts_single_object_statement() { + ok(json!({"Version": "2012-10-17", "Statement": allow_alice()})); + } + + #[test] + fn validate_rejects_non_alphanumeric_sid() { + for sid in ["Sid-X", "Sid_X", "Sid X", "Sid.X", "Sid\u{00e9}"] { + let doc = json!({"Version": "2012-10-17", + "Statement": [{"Sid": sid, "Effect": "Allow", + "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}); + assert!( + validate_trust_policy(&doc).is_err(), + "should reject Sid {sid:?}" + ); + } + } + + #[test] + fn validate_accepts_alphanumeric_or_empty_sid() { + for sid in ["Abc123", "", "aaaaaaaaaa"] { + let doc = json!({"Version": "2012-10-17", + "Statement": [{"Sid": sid, "Effect": "Allow", + "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}); + assert!( + validate_trust_policy(&doc).is_ok(), + "should accept Sid {sid:?}" + ); + } + } + + #[test] + fn validate_accepts_lowercase_effect() { + ok( + json!({"Statement": [{"Effect": "deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_accepts_numeric_condition_value() { + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": 5}} + }]})); + } + + #[test] + fn validate_accepts_unknown_condition_key() { + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:NoSuchKey": "x"}} + }]})); + } + + #[test] + fn validate_accepts_known_iam_operator_extenddb_does_not_evaluate() { + // BinaryEquals is a valid IAM operator the evaluator does not implement; + // IAM accepts the document, and a Deny using it fails closed at + // evaluation. + ok(json!({"Statement": [{ + "Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"BinaryEquals": {"aws:PrincipalTag/x": "AAAA"}} + }]})); + } + + #[test] + fn validate_accepts_aws_wildcard_principal_and_account() { + ok( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"}]}), + ); + ok( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": ACCT}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_accepts_tagsession_and_setsourceidentity_actions() { + ok( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": ["sts:AssumeRole", "sts:TagSession", "sts:SetSourceIdentity"]}]}), + ); + } + + // ---- validate_trust_policy: reject set -------------------------------- + + #[test] + fn validate_rejects_missing_statement() { + rejected(json!({"Version": "2012-10-17"})); + } + + #[test] + fn validate_rejects_missing_effect() { + rejected( + json!({"Statement": [{"Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_invalid_effect() { + rejected( + json!({"Statement": [{"Effect": "Permit", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_not_principal() { + rejected( + json!({"Statement": [{"Effect": "Deny", "NotPrincipal": {"AWS": BOB}, "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_missing_principal() { + rejected(json!({"Statement": [{"Effect": "Deny", "Action": "sts:AssumeRole"}]})); + } + + #[test] + fn validate_rejects_bare_star_principal() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}]}), + ); + } + + #[test] + fn validate_rejects_wildcard_in_principal_arn() { + rejected(json!({"Statement": [{"Effect": "Deny", + "Principal": {"AWS": format!("arn:aws:iam::{ACCT}:user/dev-*")}, "Action": "sts:AssumeRole"}]})); + } + + #[test] + fn validate_rejects_missing_action() { + rejected(json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}}]})); + } + + #[test] + fn validate_rejects_sts_star_action() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "sts:*"}]}), + ); + } + + #[test] + fn validate_rejects_non_sts_action() { + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, "Action": "dynamodb:GetItem"}]}), + ); + } + + #[test] + fn validate_rejects_resource_present() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", "Resource": "*"}]}), + ); + } + + #[test] + fn validate_rejects_char_above_latin1() { + // The service will not store a trust policy with a code point above + // U+00FF (CreateRole InternalFailure). A key name with U+0100 is + // rejected; a Latin-1 key (sharp s) is accepted (stored, reads absent + // at evaluation). + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:Reque\u{0100}edRegion": "us-east-1"}}}]}), + ); + // A high code point in a value is rejected too. + rejected( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1\u{212a}"}}}]}), + ); + // Latin-1 is accepted. + ok( + json!({"Statement": [{"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:Reque\u{00df}edRegion": "us-east-1"}}}]}), + ); + } + + #[test] + fn validate_rejects_unknown_condition_operator() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEqualz": {"aws:PrincipalTag/team": "contractors"}}}]}), + ); + } + + #[test] + fn validate_rejects_miscased_condition_operators() { + // The live service treats operator names, set qualifiers and the + // IfExists suffix as case-sensitive and rejects a miscased one at the + // trust write (measured); ExtendDB matches. + for op in [ + "stringequals", + "bool", + "forallvalues:StringEquals", + "ForAllValues:stringequals", + "StringEqualsifexists", + ] { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {op: {"aws:PrincipalType": "User"}}}]}), + ); + } + } + + #[test] + fn validate_rejects_condition_not_object() { + rejected( + json!({"Statement": [{"Effect": "Deny", "Principal": {"AWS": CALLER}, "Action": "sts:AssumeRole", + "Condition": {"StringEquals": "contractors"}}]}), + ); + } + + // ---- resolved request-wide keys on the trust path -------------------- + + #[test] + fn trust_tls_guard_does_not_block_over_tls() { + // T8: Allow alice, Deny * if Bool aws:SecureTransport=false. The call is + // over TLS, so SecureTransport is true, the deny does not fire, and the + // allow grants. Matches live STS (ALLOW). + let stmts = vec![ + allow_alice(), + json!({"Effect": "Deny", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", + "Condition": {"Bool": {"aws:SecureTransport": "false"}}}), + ]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_secure_transport_true_grants() { + let stmts = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"Bool": {"aws:SecureTransport": "true"}}})]; + assert!(trust_decision(&stmts, CALLER, &ctx(), false)); + } + + #[test] + fn trust_principal_type_user_grants_and_assumedrole_does_not() { + // The caller is an IAM user, so PrincipalType=User grants and + // PrincipalType=AssumedRole does not. Matches live STS. + let user_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalType": "User"}}})]; + assert!(trust_decision(&user_gate, CALLER, &ctx(), false)); + let role_gate = vec![json!({"Effect": "Allow", "Principal": {"AWS": CALLER}, + "Action": "sts:AssumeRole", + "Condition": {"StringEquals": {"aws:PrincipalType": "AssumedRole"}}})]; + assert!(!trust_decision(&role_gate, CALLER, &ctx(), false)); + } + + #[test] + fn caller_principal_type_and_username_derivation() { + assert_eq!(caller_principal_type(CALLER), Some("User")); + assert_eq!(caller_username(CALLER), Some("alice")); + let role = "arn:aws:sts::123456789012:assumed-role/app-role/session-1"; + assert_eq!(caller_principal_type(role), Some("AssumedRole")); + assert_eq!(caller_username(role), None); + let root = "arn:aws:iam::123456789012:root"; + assert_eq!(caller_principal_type(root), None); + assert_eq!(caller_username(root), None); + } +} diff --git a/crates/server/src/management/cache_invalidate.rs b/crates/server/src/management/cache_invalidate.rs index 255a3dc1b..5f04d9c20 100644 --- a/crates/server/src/management/cache_invalidate.rs +++ b/crates/server/src/management/cache_invalidate.rs @@ -177,6 +177,9 @@ pub async fn apply( .invalidate_user_boundary(account_id, user_name) .await; auth_cache.invalidate_user_tags(account_id, user_name).await; + auth_cache + .invalidate_user_created_at(account_id, user_name) + .await; auth_cache .invalidate_principal_credentials(account_id, user_name) .await; @@ -185,6 +188,7 @@ pub async fn apply( "user_group_policies", "user_boundary", "user_tags", + "user_created_at", "principal_credentials", ] } @@ -201,6 +205,9 @@ pub async fn apply( auth_cache .invalidate_role_sessions(account_id, role_name) .await; + auth_cache + .invalidate_role_created_at(account_id, role_name) + .await; auth_cache .invalidate_principal_credentials(account_id, role_name) .await; @@ -209,6 +216,7 @@ pub async fn apply( "role_boundary", "role_tags", "role_sessions", + "role_created_at", "principal_credentials", ] } diff --git a/crates/server/src/management/iam_policy.rs b/crates/server/src/management/iam_policy.rs index 4df605f2a..7328902f5 100755 --- a/crates/server/src/management/iam_policy.rs +++ b/crates/server/src/management/iam_policy.rs @@ -183,7 +183,9 @@ async fn put_policy( // Strict parse-on-write: reject documents the policy evaluator cannot parse. { let json_str = document.to_string(); - if let Err(e) = extenddb_auth::policy::document::PolicyDocument::from_json(&json_str) { + if let Err(e) = + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&json_str) + { return ( StatusCode::BAD_REQUEST, format!("Invalid policy document: {e}"), diff --git a/crates/server/src/management/iam_role.rs b/crates/server/src/management/iam_role.rs index 47e424ff7..a05d131b2 100755 --- a/crates/server/src/management/iam_role.rs +++ b/crates/server/src/management/iam_role.rs @@ -66,11 +66,18 @@ pub async fn create_role( )); } - if body.trust_policy.get("Version").is_none() || body.trust_policy.get("Statement").is_none() { + if body.trust_policy.get("Statement").is_none() { return op_err_to_response(OpError::Validation( - "Trust policy must contain Version and Statement".to_owned(), + "Trust policy must contain a Statement".to_owned(), )); } + // Validate the trust document the way IAM validates an AssumeRole trust + // policy, so a document IAM would reject is not stored and then dropped at + // evaluation. The error text is ExtendDB's own; the accepted and rejected + // sets match IAM. + if let Err(msg) = super::assume_role::validate_trust_policy(&body.trust_policy) { + return op_err_to_response(OpError::Validation(format!("Invalid trust policy: {msg}"))); + } match state .catalog_store @@ -162,6 +169,10 @@ pub async fn delete_role( .auth_cache .invalidate_role_sessions(&account_id, &role_name) .await; + state + .auth_cache + .invalidate_role_created_at(&account_id, &role_name) + .await; StatusCode::NO_CONTENT.into_response() } Err(e) => op_err_to_response(OpError::from_storage(e)), @@ -182,15 +193,8 @@ pub async fn tag_role( } for tag in &body.tags { - if tag.key.is_empty() || tag.key.len() > 128 { - return (StatusCode::BAD_REQUEST, "tag key must be 1-128 characters").into_response(); - } - if tag.value.len() > 256 { - return ( - StatusCode::BAD_REQUEST, - "tag value must not exceed 256 characters", - ) - .into_response(); + if tag.key.is_empty() { + return (StatusCode::BAD_REQUEST, "tag key must not be empty").into_response(); } } @@ -200,6 +204,17 @@ pub async fn tag_role( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Err(e) = super::validate_iam_tags(&tags) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } + if let Some(dup) = super::duplicate_tag_key_ci(&tags) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + match state .catalog_store .tag_role(&account_id, &role_name, &tags) diff --git a/crates/server/src/management/iam_user.rs b/crates/server/src/management/iam_user.rs index f60f5d703..8e9a8d583 100755 --- a/crates/server/src/management/iam_user.rs +++ b/crates/server/src/management/iam_user.rs @@ -207,6 +207,10 @@ pub async fn delete_user( .auth_cache .invalidate_user_tags(&account_id, &user_name) .await; + state + .auth_cache + .invalidate_user_created_at(&account_id, &user_name) + .await; StatusCode::NO_CONTENT.into_response() } Err(e) => op_err_to_response(OpError::from_storage(e)), @@ -227,15 +231,8 @@ pub async fn tag_user( } for tag in &body.tags { - if tag.key.is_empty() || tag.key.len() > 128 { - return (StatusCode::BAD_REQUEST, "tag key must be 1-128 characters").into_response(); - } - if tag.value.len() > 256 { - return ( - StatusCode::BAD_REQUEST, - "tag value must not exceed 256 characters", - ) - .into_response(); + if tag.key.is_empty() { + return (StatusCode::BAD_REQUEST, "tag key must not be empty").into_response(); } } @@ -245,6 +242,17 @@ pub async fn tag_user( .map(|t| (t.key.clone(), t.value.clone())) .collect(); + if let Err(e) = super::validate_iam_tags(&tags) { + return (StatusCode::BAD_REQUEST, e).into_response(); + } + if let Some(dup) = super::duplicate_tag_key_ci(&tags) { + return ( + StatusCode::BAD_REQUEST, + format!("Duplicate tag keys found (tag keys are case insensitive): {dup}"), + ) + .into_response(); + } + match state .catalog_store .tag_user(&account_id, &user_name, &tags) diff --git a/crates/server/src/management/mod.rs b/crates/server/src/management/mod.rs index 0682c98fc..cbe651ded 100755 --- a/crates/server/src/management/mod.rs +++ b/crates/server/src/management/mod.rs @@ -12,6 +12,9 @@ mod account; pub(crate) use account::generate_account_id; mod admin; mod assume_role; +/// Shared with the web console so both write surfaces validate a trust +/// policy the same way. +pub(crate) use assume_role::validate_trust_policy; pub(crate) mod auth; pub mod cache_invalidate; mod cache_metrics; @@ -49,6 +52,12 @@ pub struct ManagementState { /// Concrete `TableKeyInfo` cache handle, used by the auth-cache-metrics /// endpoint. Same instance is held in `auth_cache.table_key_info`. pub table_key_info_cache: Arc, + /// Server region, resolved into `aws:RequestedRegion` during trust policy + /// evaluation. + pub region: Arc, + /// Whether the listener serves TLS, resolved into `aws:SecureTransport` + /// during trust policy evaluation. + pub secure_transport: bool, } /// Build the management API router. @@ -221,6 +230,82 @@ pub(crate) fn is_valid_iam_name(name: &str) -> bool { }) } +/// Reject a tag set with two keys that collide under ASCII case folding. +/// +/// The live service treats tag keys as case insensitive for uniqueness: a +/// `TagUser`/`TagRole`/`TagResource` request carrying both `Team` and `team` +/// is rejected ("Duplicate tag keys found. Please note that Tag keys are case +/// insensitive."). Two keys that differ by more than ASCII case (for example +/// `mass` and `ma\u{00df}`) are distinct and allowed. Returns the duplicate key +/// when found. +pub(crate) fn duplicate_tag_key_ci(tags: &[(String, String)]) -> Option { + let mut seen: Vec = Vec::with_capacity(tags.len()); + for (k, _) in tags { + let lower = k.to_ascii_lowercase(); + if seen.contains(&lower) { + return Some(k.clone()); + } + seen.push(lower); + } + None +} + +/// Validate a tag set against the live IAM service's tag rules, returning an +/// error string for the first violation. Each key is 1-128 characters and each +/// value at most 256 characters (counted in characters, not bytes), drawn from +/// IAM's grammar (`[\p{L}\p{Z}\p{N}_.:/=+\-@]`, which excludes combining +/// marks and most punctuation), and a key must not use the reserved `aws:` +/// prefix. The empty value and a leading or trailing space are allowed, matching +/// the service. +pub(crate) fn validate_iam_tags(tags: &[(String, String)]) -> Result<(), String> { + for (k, v) in tags { + let n = k.chars().count(); + if n == 0 || n > 128 { + return Err("tag key must be 1-128 characters".to_owned()); + } + if k.to_ascii_lowercase().starts_with("aws:") { + return Err("tag key must not begin with the reserved prefix aws:".to_owned()); + } + if !k.chars().all(is_iam_tag_char) { + return Err(format!("tag key contains an invalid character: {k}")); + } + if v.chars().count() > 256 { + return Err("tag value must not exceed 256 characters".to_owned()); + } + if !v.chars().all(is_iam_tag_char) { + return Err("tag value contains an invalid character".to_owned()); + } + } + Ok(()) +} + +/// Whether `c` is admissible in an IAM tag key or value. +/// +/// IAM's tag grammar is `[\p{L}\p{Z}\p{N}_.:/=+\-@]`: any Unicode Letter +/// (Lu, Ll, Lt, Lm, Lo), Number (Nd, Nl, No) or Separator (Zs, Zl, Zp), plus the +/// listed symbols. These are Unicode general categories, matched here through +/// `general_category_group`, not `char::is_alphabetic` / `char::is_numeric`: +/// `is_alphabetic` is the Alphabetic property, a superset of `\p{L}` that admits +/// Other_Alphabetic combining marks such as U+0345 (category Mn) that IAM +/// rejects, and an ASCII-space-only separator check rejects the `\p{Z}` +/// separators (U+00A0, U+2002, U+3000 and the rest) that IAM accepts. +/// +/// `unicode-properties` carries Unicode 15.1 general categories; the case-fold +/// table in `extenddb_auth` is Unicode 15.0.0. Unicode 15.1 added only uncased +/// characters (CJK Unified Ideographs Extension I and five ideographic +/// description characters), so its case folding is identical to 15.0.0 and +/// the fold table holds under either version. +fn is_iam_tag_char(c: char) -> bool { + use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; + matches!(c, '_' | '.' | ':' | '/' | '=' | '+' | '-' | '@') + || matches!( + c.general_category_group(), + GeneralCategoryGroup::Letter + | GeneralCategoryGroup::Number + | GeneralCategoryGroup::Separator + ) +} + /// Validate admin name: 1-64 chars, alphanumeric, hyphens, or underscores. fn is_valid_admin_name(name: &str) -> bool { !name.is_empty() @@ -229,3 +314,82 @@ fn is_valid_admin_name(name: &str) -> bool { .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } + +#[cfg(test)] +mod tag_validation_tests { + use super::{duplicate_tag_key_ci, validate_iam_tags}; + + fn t(k: &str, v: &str) -> Vec<(String, String)> { + vec![(k.to_owned(), v.to_owned())] + } + + #[test] + fn accepts_service_valid_tags() { + assert!(validate_iam_tags(&t("team", "prod")).is_ok()); + assert!(validate_iam_tags(&t("a_.:/=+-@b", "v_.:/=+-@")).is_ok()); + assert!(validate_iam_tags(&t("my team", "east coast")).is_ok()); // space ok + assert!(validate_iam_tags(&t("team", "")).is_ok()); // empty value ok + assert!(validate_iam_tags(&t(" team", "prod")).is_ok()); // leading space ok + assert!(validate_iam_tags(&t("equipe\u{00e9}", "prod")).is_ok()); // precomposed letter + } + + #[test] + fn rejects_service_invalid_tags() { + assert!(validate_iam_tags(&t("Equipe\u{0301}", "prod")).is_err()); // combining mark + assert!(validate_iam_tags(&t("a#b", "prod")).is_err()); + assert!(validate_iam_tags(&t("a!b", "prod")).is_err()); + assert!(validate_iam_tags(&t("aws:team", "prod")).is_err()); // reserved prefix + assert!(validate_iam_tags(&t("AWS:team", "prod")).is_err()); // case-insensitive prefix + assert!(validate_iam_tags(&t(&"k".repeat(129), "prod")).is_err()); // key too long + assert!(validate_iam_tags(&t("team", &"v".repeat(257))).is_err()); // value too long + assert!(validate_iam_tags(&t("team", "v\u{0301}")).is_err()); // combining mark in value + } + + #[test] + fn tag_charset_matches_unicode_general_categories() { + // Separators (Zs, Zl, Zp) are accepted, matching live IAM. + for sep in [ + '\u{00a0}', // no-break space (Zs) + '\u{2002}', // en space (Zs) + '\u{2003}', // em space (Zs) + '\u{3000}', // ideographic space (Zs) + '\u{2028}', // line separator (Zl) + '\u{2029}', // paragraph separator (Zp) + '\u{202f}', // narrow no-break space (Zs) + ] { + let k = format!("a{sep}b"); + assert!(validate_iam_tags(&t(&k, "v")).is_ok(), "sep {sep:?} key"); + assert!(validate_iam_tags(&t("k", &k)).is_ok(), "sep {sep:?} value"); + } + // Numbers beyond Nd: Nl and No are accepted. + assert!(validate_iam_tags(&t("a\u{2160}b", "v")).is_ok()); // roman numeral one (Nl) + assert!(validate_iam_tags(&t("a\u{00b2}b", "v")).is_ok()); // superscript two (No) + assert!(validate_iam_tags(&t("a\u{00bd}b", "v")).is_ok()); // vulgar fraction half (No) + // Marks (Mn) are rejected even when Alphabetic (Other_Alphabetic), + // matching live IAM. + assert!(validate_iam_tags(&t("a\u{0345}b", "v")).is_err()); // ypogegrammeni (Mn, Other_Alphabetic) + assert!(validate_iam_tags(&t("a\u{0301}b", "v")).is_err()); // combining acute (Mn) + assert!(validate_iam_tags(&t("k", "v\u{0345}")).is_err()); // in a value too + // Symbols are rejected: an emoji (So). + assert!(validate_iam_tags(&t("a\u{1f600}b", "v")).is_err()); + // Control: ASCII space (Zs) still accepted. + assert!(validate_iam_tags(&t("a b", "v")).is_ok()); + } + + #[test] + fn duplicate_ascii_case_key_detected() { + let dup = duplicate_tag_key_ci(&[ + ("Team".to_owned(), "1".to_owned()), + ("team".to_owned(), "2".to_owned()), + ]); + assert_eq!(dup.as_deref(), Some("team")); + // mass and sharp-s differ by more than ASCII case: distinct, allowed. + assert!( + duplicate_tag_key_ci(&[ + ("mass".to_owned(), "A".to_owned()), + ("ma\u{00df}".to_owned(), "B".to_owned()), + ]) + .is_none() + ); + } +} diff --git a/crates/server/src/management/permissions_boundary.rs b/crates/server/src/management/permissions_boundary.rs index 346891da3..f1e10e9ca 100755 --- a/crates/server/src/management/permissions_boundary.rs +++ b/crates/server/src/management/permissions_boundary.rs @@ -95,6 +95,23 @@ async fn set_boundary( .into_response(); } + // Strict parse-on-write: reject a document the policy evaluator cannot + // parse, including a malformed CIDR in an IpAddress/NotIpAddress condition. + // The live service validates a managed/boundary policy at write and rejects + // a malformed CIDR with MalformedPolicyDocument. + { + let json_str = document.to_string(); + if let Err(e) = + extenddb_auth::policy::document::PolicyDocument::from_json_for_write(&json_str) + { + return ( + StatusCode::BAD_REQUEST, + format!("Invalid permissions boundary document: {e}"), + ) + .into_response(); + } + } + let result = match principal_type { "user" => { state diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 923b5e483..583ac89f3 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -61,6 +61,8 @@ pub(crate) async fn authorize_request( input: &Value, operation: &str, account_id: &str, + source_ip: Option<&str>, + user_agent: Option<&str>, ) -> Result, DynamoDbError> { let table_name = extract_table_name(input); @@ -115,7 +117,13 @@ pub(crate) async fn authorize_request( &action_op, &resource_arn, false, - extenddb_auth::policy::context::RequestParams::default(), + extenddb_auth::policy::context::RequestParams { + region: Some(state.region.to_string()), + secure_transport: state.tls_enabled, + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), + ..Default::default() + }, ) .await?; } @@ -143,6 +151,10 @@ pub(crate) async fn authorize_request( .get("ReturnConsumedCapacity") .and_then(|v| v.as_str()) .map(ToOwned::to_owned), + region: Some(state.region.to_string()), + secure_transport: state.tls_enabled, + source_ip: source_ip.map(ToOwned::to_owned), + user_agent: user_agent.map(ToOwned::to_owned), ..Default::default() }; authorization::check_authorization( diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 1b0da34fb..b66bf0402 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -261,7 +261,7 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT session_policy, session_tags FROM {catalog_keyspace}.iam_sessions \ + "SELECT session_policy, session_tags, created_at FROM {catalog_keyspace}.iam_sessions \ WHERE account_id = ? AND role_name = ? AND session_name = ? ALLOW FILTERING" ); @@ -323,6 +323,16 @@ impl AuthorizationStore for CassandraCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_session_data", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }), })) }) } @@ -479,4 +489,76 @@ impl AuthorizationStore for CassandraCatalogStore { Ok(tags) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + Box::pin(async move { + let query = format!( + "SELECT created_at FROM {catalog_keyspace}.iam_users \ + WHERE account_id = ? AND user_name = ?" + ); + let rows = crate::cassandra_util::query_rows::( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + "fetch_user_created_at", + ) + .await?; + Ok(rows.first().and_then(|row| { + crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_user_created_at", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + Box::pin(async move { + let query = format!( + "SELECT created_at FROM {catalog_keyspace}.iam_roles \ + WHERE account_id = ? AND role_name = ?" + ); + let rows = crate::cassandra_util::query_rows::( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), role_name.as_str()), + "fetch_role_created_at", + ) + .await?; + Ok(rows.first().and_then(|row| { + crate::cassandra_util::get_timestamp::( + row, + "created_at", + "fetch_role_created_at", + ) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + })) + }) + } } diff --git a/crates/storage-mongodb/src/authorization_store.rs b/crates/storage-mongodb/src/authorization_store.rs index 6f7128088..bf3f33c7d 100644 --- a/crates/storage-mongodb/src/authorization_store.rs +++ b/crates/storage-mongodb/src/authorization_store.rs @@ -263,6 +263,10 @@ impl AuthorizationStore for MongoCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: session_doc + .get_datetime("created_at") + .ok() + .and_then(bson_datetime_to_rfc3339), })) }) } @@ -357,4 +361,63 @@ impl AuthorizationStore for MongoCatalogStore { .collect()) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let coll = self.catalog_db().collection::("iam_users"); + let doc = coll + .find_one(doc! { "account_id": &account_id, "user_name": &user_name }) + .await + .map_err(|e| { + tracing::error!("fetch_user_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(doc.and_then(|d| { + d.get_datetime("created_at") + .ok() + .and_then(bson_datetime_to_rfc3339) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let coll = self.catalog_db().collection::("iam_roles"); + let doc = coll + .find_one(doc! { "account_id": &account_id, "role_name": &role_name }) + .await + .map_err(|e| { + tracing::error!("fetch_role_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(doc.and_then(|d| { + d.get_datetime("created_at") + .ok() + .and_then(bson_datetime_to_rfc3339) + })) + }) + } +} + +/// Format a BSON datetime as RFC 3339 UTC, matching how the other backends +/// return `created_at` for the `aws:userid` derivation and `aws:TokenIssueTime`. +fn bson_datetime_to_rfc3339(dt: &mongodb::bson::DateTime) -> Option { + time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(dt.timestamp_millis()) * 1_000_000) + .ok() + .and_then(|odt| { + odt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) } diff --git a/crates/storage-mongodb/src/management_store.rs b/crates/storage-mongodb/src/management_store.rs index 4a034e666..bf38a1395 100644 --- a/crates/storage-mongodb/src/management_store.rs +++ b/crates/storage-mongodb/src/management_store.rs @@ -1840,6 +1840,7 @@ impl ManagementStore for MongoCatalogStore { "role_name": &role_name, "session_name": &session_name, "expires_at": expires_bson, + "created_at": now_bson(), }; if let Some(ref tags) = session_tags { diff --git a/crates/storage-postgres/src/authorization_store.rs b/crates/storage-postgres/src/authorization_store.rs index 56ade5a0d..c39814ceb 100755 --- a/crates/storage-postgres/src/authorization_store.rs +++ b/crates/storage-postgres/src/authorization_store.rs @@ -144,23 +144,26 @@ impl AuthorizationStore for PostgresCatalogStore { let role_name = role_name.to_owned(); let session_name = session_name.to_owned(); Box::pin(async move { - let row: Option<(Option, Option)> = - sqlx::query_as( - "SELECT session_policy, session_tags FROM iam_sessions \ + let row: Option<( + Option, + Option, + time::OffsetDateTime, + )> = sqlx::query_as( + "SELECT session_policy, session_tags, created_at FROM iam_sessions \ WHERE account_id = $1 AND role_name = $2 AND session_name = $3 \ AND expires_at > now()", - ) - .bind(&account_id) - .bind(&role_name) - .bind(&session_name) - .fetch_optional(self.pool()) - .await - .map_err(|e| { - tracing::error!("fetch_session_data: {e}"); - OpError::Internal("Database error".to_owned()) - })?; + ) + .bind(&account_id) + .bind(&role_name) + .bind(&session_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_session_data: {e}"); + OpError::Internal("Database error".to_owned()) + })?; - let Some((policy_value, tags_value)) = row else { + let Some((policy_value, tags_value, created_at)) = row else { return Ok(None); }; @@ -189,6 +192,9 @@ impl AuthorizationStore for PostgresCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: created_at + .format(&time::format_description::well_known::Rfc3339) + .ok(), })) }) } @@ -252,4 +258,56 @@ impl AuthorizationStore for PostgresCatalogStore { }) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let row: Option<(time::OffsetDateTime,)> = sqlx::query_as( + "SELECT created_at FROM iam_users WHERE account_id = $1 AND user_name = $2", + ) + .bind(&account_id) + .bind(&user_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_user_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(row.and_then(|(ts,)| { + ts.format(&time::format_description::well_known::Rfc3339) + .ok() + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let row: Option<(time::OffsetDateTime,)> = sqlx::query_as( + "SELECT created_at FROM iam_roles WHERE account_id = $1 AND role_name = $2", + ) + .bind(&account_id) + .bind(&role_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| { + tracing::error!("fetch_role_created_at: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(row.and_then(|(ts,)| { + ts.format(&time::format_description::well_known::Rfc3339) + .ok() + })) + }) + } } diff --git a/crates/storage-sqlite/src/authorization_store.rs b/crates/storage-sqlite/src/authorization_store.rs index edf5ac66f..8c6a00cbe 100644 --- a/crates/storage-sqlite/src/authorization_store.rs +++ b/crates/storage-sqlite/src/authorization_store.rs @@ -136,8 +136,8 @@ impl AuthorizationStore for SqliteCatalogStore { let role_name = role_name.to_owned(); let session_name = session_name.to_owned(); Box::pin(async move { - let row: Option<(Option, Option, String)> = sqlx::query_as( - "SELECT session_policy, session_tags, expires_at FROM iam_sessions \ + let row: Option<(Option, Option, String, String)> = sqlx::query_as( + "SELECT session_policy, session_tags, expires_at, created_at FROM iam_sessions \ WHERE account_id = ? AND role_name = ? AND session_name = ?", ) .bind(&account_id) @@ -147,7 +147,7 @@ impl AuthorizationStore for SqliteCatalogStore { .await .map_err(|e| db_err("fetch_session_data", e))?; - let Some((session_policy, tags_json, expires_at)) = row else { + let Some((session_policy, tags_json, expires_at, created_at)) = row else { return Ok(None); }; @@ -193,6 +193,10 @@ impl AuthorizationStore for SqliteCatalogStore { Ok(Some(SessionData { session_policy, session_tags, + created_at: parse_timestamp(&created_at).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }), })) }) } @@ -249,4 +253,54 @@ impl AuthorizationStore for SqliteCatalogStore { .map_err(|e| db_err("fetch_resource_tags", e)) }) } + + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + Box::pin(async move { + let row: Option<(String,)> = sqlx::query_as( + "SELECT created_at FROM iam_users WHERE account_id = ? AND user_name = ?", + ) + .bind(&account_id) + .bind(&user_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| db_err("fetch_user_created_at", e))?; + Ok(row.and_then(|(ts,)| { + parse_timestamp(&ts).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + })) + }) + } + + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + Box::pin(async move { + let row: Option<(String,)> = sqlx::query_as( + "SELECT created_at FROM iam_roles WHERE account_id = ? AND role_name = ?", + ) + .bind(&account_id) + .bind(&role_name) + .fetch_optional(self.pool()) + .await + .map_err(|e| db_err("fetch_role_created_at", e))?; + Ok(row.and_then(|(ts,)| { + parse_timestamp(&ts).ok().and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + })) + }) + } } diff --git a/crates/storage-sqlite/src/management_store/mod.rs b/crates/storage-sqlite/src/management_store/mod.rs index 8f858413e..e9a9d5688 100644 --- a/crates/storage-sqlite/src/management_store/mod.rs +++ b/crates/storage-sqlite/src/management_store/mod.rs @@ -591,9 +591,10 @@ impl ManagementStore for SqliteCatalogStore { let account_id = account_id.to_owned(); let resource = resource.to_owned(); Box::pin(async move { - // Resolve the principal kind and name from the ARN resource part. - if let Some(rest) = resource.split(":user/").nth(1) { - let user_name = rest.trim_end_matches('/'); + // The server passes the ARN resource part, e.g. `user/`, + // `role/`, or `assumed-role//`. Match the same + // prefixes the other backends use so caller tags resolve on SQLite. + if let Some(user_name) = resource.strip_prefix("user/") { return sqlx::query_as( "SELECT tag_key, tag_value FROM iam_user_tags \ WHERE account_id = ? AND user_name = ? ORDER BY tag_key", @@ -610,12 +611,14 @@ impl ManagementStore for SqliteCatalogStore { // assumed-role/RoleName/SessionName or role/RoleName → role tags. let role_name = resource - .split(":assumed-role/") - .nth(1) - .or_else(|| resource.split(":role/").nth(1)) + .strip_prefix("assumed-role/") + .or_else(|| resource.strip_prefix("role/")) .map(|rest| rest.split('/').next().unwrap_or(rest)); if let Some(role_name) = role_name { + if role_name.is_empty() { + return Ok(Vec::new()); + } return sqlx::query_as( "SELECT tag_key, tag_value FROM iam_role_tags \ WHERE account_id = ? AND role_name = ? ORDER BY tag_key", diff --git a/crates/storage-sqlite/src/store.rs b/crates/storage-sqlite/src/store.rs index 32eee9a51..055bac8f2 100644 --- a/crates/storage-sqlite/src/store.rs +++ b/crates/storage-sqlite/src/store.rs @@ -688,6 +688,63 @@ mod d1_write_lock_tests { .await; } + /// The caller-tag lookup resolves tags for a `user/` resource, which + /// is the form the server passes from the caller ARN. It also resolves a + /// `role/` and an `assumed-role//` resource to the + /// role's tags. + #[tokio::test] + async fn fetch_caller_tags_resolves_user_and_role() { + use extenddb_storage::management_store::ManagementStore; + let engine = engine().await; + let store = catalog_store(&engine); + + ManagementStore::create_user(&store, "000000000000", "tag-user", None) + .await + .expect("create user"); + ManagementStore::tag_user( + &store, + "000000000000", + "tag-user", + &[("team".to_owned(), "contractors".to_owned())], + ) + .await + .expect("tag user"); + let tags = ManagementStore::fetch_caller_tags(&store, "000000000000", "user/tag-user") + .await + .expect("fetch user tags"); + assert_eq!(tags, vec![("team".to_owned(), "contractors".to_owned())]); + + ManagementStore::create_role( + &store, + "000000000000", + "tag-role", + &json!({"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"}]}), + ) + .await + .expect("create role"); + ManagementStore::tag_role( + &store, + "000000000000", + "tag-role", + &[("env".to_owned(), "prod".to_owned())], + ) + .await + .expect("tag role"); + let role_tags = ManagementStore::fetch_caller_tags(&store, "000000000000", "role/tag-role") + .await + .expect("fetch role tags"); + assert_eq!(role_tags, vec![("env".to_owned(), "prod".to_owned())]); + let assumed = ManagementStore::fetch_caller_tags( + &store, + "000000000000", + "assumed-role/tag-role/session1", + ) + .await + .expect("fetch assumed-role tags"); + assert_eq!(assumed, vec![("env".to_owned(), "prod".to_owned())]); + } + #[tokio::test] async fn management_create_access_key_waits_for_the_write_lock() { use extenddb_storage::management_store::ManagementStore; diff --git a/crates/storage/src/authorization_store.rs b/crates/storage/src/authorization_store.rs index f8490783c..87e374b3f 100755 --- a/crates/storage/src/authorization_store.rs +++ b/crates/storage/src/authorization_store.rs @@ -77,6 +77,27 @@ pub trait AuthorizationStore: Send + Sync { /// Fetch tags for a resource ARN (for condition key evaluation). fn fetch_resource_tags(&self, arn: &str) -> BoxFuture<'_, OpResult>>; + + /// Fetch a user's creation time (`iam_users.created_at`) as RFC 3339 UTC. + /// + /// Returns `None` if the user does not exist. The value is a stable part of + /// the user's `aws:userid` derivation, so a user deleted and recreated with + /// the same name (a new creation time) gets a new id, as IAM does. + fn fetch_user_created_at( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>>; + + /// Fetch a role's creation time (`iam_roles.created_at`) as RFC 3339 UTC. + /// + /// Returns `None` if the role does not exist. Used the same way as + /// [`AuthorizationStore::fetch_user_created_at`] for a role's `aws:userid`. + fn fetch_role_created_at( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>>; } /// Session data returned by [`AuthorizationStore::fetch_session_data`]. @@ -86,4 +107,8 @@ pub struct SessionData { pub session_policy: Option, /// Session tags as `(key, value)` pairs. pub session_tags: Vec<(String, String)>, + /// The session issue time (`iam_sessions.created_at`) formatted as RFC 3339 + /// UTC, which is the value of `aws:TokenIssueTime` for the role session. + /// `None` only if the stored value could not be read or parsed. + pub created_at: Option, } diff --git a/devtools/generate-casefold-table b/devtools/generate-casefold-table new file mode 100755 index 000000000..90eeb610c --- /dev/null +++ b/devtools/generate-casefold-table @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +"""Generate crates/auth/src/policy/casefold.rs from Python's str.casefold. + +Python's str.casefold implements Unicode full case folding (the Case Folding +data's C + F mappings), which is what the live service applies to condition key +names and tag keys on the DynamoDB request path. The table maps every code point +whose fold differs from itself to its folded form; a code point absent from the +table folds to itself (so the dotless i U+0131 and the dotted I U+0130, which do +not fold to plain ASCII i, are handled correctly). + +The Unicode version is whatever the running Python's unicodedata reports; it is +recorded in the generated file header. Run from the repository root: + + devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs + +No Unicode data file is vendored; the data comes from the Python standard +library. +""" +import subprocess +import sys +import unicodedata + +OUT_PATH = "crates/auth/src/policy/casefold.rs" + + +def entries(): + for c in range(0x110000): + ch = chr(c) + f = ch.casefold() + if f != ch: + yield c, f + + +def esc(folded): + return "".join("\\u{%x}" % ord(x) for x in folded) + + +def main(): + uni = unicodedata.unidata_version + rows = list(entries()) + out = [] + w = out.append + w("// Copyright 2026 ExtendDB contributors") + w("// SPDX-License-Identifier: Apache-2.0") + w("") + w("//! Unicode full case folding (the Case Folding data's full `C` + `F`") + w("//! mappings). The live service resolves a condition key name and a tag key") + w("//! on the `DynamoDB` request path under full case folding, so a name spelled") + w("//! with a non-ASCII letter that folds to a resolvable key (long s to s, the") + w("//! st ligature to st, sharp s to ss) is matched. The dotless i (U+0131) and") + w("//! the dotted I (U+0130) do not fold to a plain ASCII `i` and so read as") + w("//! absent, matching the service.") + w("//!") + w(f"//! Unicode version: {uni}. Generated by `devtools/generate-casefold-table`") + w("//! from Python's `str.casefold`; do not edit by hand. Regenerate with") + w("//! `devtools/generate-casefold-table > crates/auth/src/policy/casefold.rs`.") + w("") + w("/// Code point to its full case fold. Code points not listed fold to") + w("/// themselves. Sorted by code point for binary search.") + w("pub(crate) static CASE_FOLD: &[(u32, &str)] = &[") + for c, f in rows: + w(' (0x%X, "%s"),' % (c, esc(f))) + w("];") + w("") + w("/// Fold a string under Unicode full case folding, matching the service's") + w("/// resolution of condition key names and tag keys. ASCII letters fold to") + w("/// ASCII lowercase; a non-ASCII code point folds per [`CASE_FOLD`] or to") + w("/// itself.") + w("#[must_use]") + w("pub fn case_fold(s: &str) -> String {") + w(" // Fast path: an all-ASCII key (the overwhelmingly common case) folds") + w(" // to its ASCII lowercase without touching the table, since every ASCII") + w(" // letter's fold is its ASCII lowercase.") + w(" if s.is_ascii() {") + w(" return s.to_ascii_lowercase();") + w(" }") + w(" let mut out = String::with_capacity(s.len());") + w(" for c in s.chars() {") + w(" let cp = c as u32;") + w(" match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) {") + w(" Ok(i) => out.push_str(CASE_FOLD[i].1),") + w(" Err(_) => out.push(c),") + w(" }") + w(" }") + w(" out") + w("}") + w("") + w("/// Whether two strings are equal under Unicode full case folding.") + w("#[must_use]") + w("pub fn eq_fold(a: &str, b: &str) -> bool {") + w(" case_fold(a) == case_fold(b)") + w("}") + w("") + w("#[cfg(test)]") + w("mod tests {") + w(" use super::*;") + w("") + w(" #[test]") + w(" fn table_sorted_and_unique() {") + w(" for w in CASE_FOLD.windows(2) {") + w(" assert!(w[0].0 < w[1].0, \"table must be sorted and unique by code point\");") + w(" }") + w(" }") + w("") + w(" #[test]") + w(" fn spot_checks() {") + w(" // Single-character folds.") + w(" assert_eq!(case_fold(\"\\u{017f}\"), \"s\"); // long s") + w(" assert_eq!(case_fold(\"\\u{212a}\"), \"k\"); // Kelvin sign") + w(" assert_eq!(case_fold(\"A\"), \"a\");") + w(" // Multi-character expansions.") + w(" assert_eq!(case_fold(\"\\u{00df}\"), \"ss\"); // sharp s") + w(" assert_eq!(case_fold(\"\\u{1e9e}\"), \"ss\"); // capital sharp s") + w(" assert_eq!(case_fold(\"\\u{fb05}\"), \"st\"); // st ligature") + w(" // Greek final sigma folds to the same as sigma.") + w(" assert_eq!(case_fold(\"\\u{03c2}\"), case_fold(\"\\u{03c3}\"));") + w(" assert_eq!(case_fold(\"\\u{03a3}\"), case_fold(\"\\u{03c3}\"));") + w(" // Cherokee folds to another Cherokee code point (a non-ASCII target).") + w(" assert_eq!(case_fold(\"\\u{13f8}\"), \"\\u{13f0}\");") + w(" // Code points that must be absent from the table (fold to self).") + w(" assert_eq!(case_fold(\"\\u{0131}\"), \"\\u{0131}\"); // dotless i") + w(" // Dotted I folds to 'i' plus a combining dot, not plain 'i'.") + w(" assert_eq!(case_fold(\"\\u{0130}\"), \"i\\u{0307}\");") + w(" }") + w("") + w(" #[test]") + w(" fn ascii_fast_path_matches_table() {") + w(" // The ASCII fast path must agree with a per-code-point table fold") + w(" // on every ASCII string.") + w(" for s in [\"\", \"AWS:SecureTransport\", \"dynamodb:LeadingKeys\",") + w(" \"aws:PrincipalTag/Team\", \"MiXeD123_.-/\", \"abc\"] {") + w(" let table: String = s.chars().map(|c| {") + w(" let cp = c as u32;") + w(" match CASE_FOLD.binary_search_by(|&(k, _)| k.cmp(&cp)) {") + w(" Ok(i) => CASE_FOLD[i].1.to_owned(),") + w(" Err(_) => c.to_string(),") + w(" }") + w(" }).collect();") + w(" assert_eq!(case_fold(s), table, \"fast path diverged on {s:?}\");") + w(" assert_eq!(case_fold(s), s.to_ascii_lowercase());") + w(" }") + w(" }") + w("}") + w("") + source = "\n".join(out) + # Format through rustfmt so the generated file is rustfmt-clean and + # regeneration is idempotent with `cargo fmt --check`. + try: + r = subprocess.run( + ["rustfmt", "--edition", "2024", "--emit", "stdout"], + input=source, capture_output=True, text=True, check=True, + ) + source = r.stdout + except (FileNotFoundError, subprocess.CalledProcessError): + pass + sys.stdout.write(source) + + +if __name__ == "__main__": + main() diff --git a/docs/adr/0022-trust-policy-deny-over-allow.md b/docs/adr/0022-trust-policy-deny-over-allow.md new file mode 100644 index 000000000..0c789036a --- /dev/null +++ b/docs/adr/0022-trust-policy-deny-over-allow.md @@ -0,0 +1,174 @@ +# ADR-0022: Role trust-policy evaluation follows IAM Deny-over-Allow order + +- Status: Accepted +- Date: 2026-10-15 +- Deciders: @navamol + +## Context + +`AssumeRole` (`crates/server/src/management/assume_role.rs`) decides whether a +caller may assume a role by evaluating the role's trust policy against the +caller ARN. The original `evaluate_trust_policy()` iterated the statements and +skipped any whose `Effect` was not `"Allow"`: + +```rust +if effect != "Allow" { + continue; +} +``` + +A matching `Allow` returned `true`; otherwise the function returned `false` +(implicit deny). The consequence is that `Deny` statements in a trust policy +were never evaluated; they behaved as no-ops. An administrator who added a +`Deny` for a specific principal alongside a broad `Allow` (for example +`Allow Principal "*"` plus `Deny Principal `) got no protection: the denied +principal was still granted assumption through the `Allow`. + +This diverges from IAM semantics and, more importantly, from ExtendDB's own +identity-policy evaluator (`crates/auth/src/policy/evaluator.rs`), which already +implements the standard order: phase 1 is an explicit-deny scan across all +policies before any `Allow` is considered. Trust-policy evaluation and +identity-policy evaluation disagreeing on how `Deny` works is a correctness and +least-privilege hazard: the behavior a trust policy's author reasonably expects +(a `Deny` denies) is the behavior the identity evaluator already provides. + +The endpoint is admin-only (Basic Auth) and the +admin supplies the caller ARN, which bounds the practical blast radius, but the +authorization logic itself was incorrect. + +## Options Considered + +1. **Leave Allow-only, document the limitation.** Keep the existing behavior and + state in the docs that trust policies support only `Allow`. Cheapest, but it + leaves a silently-ignored `Deny` as a trap: policy authors would still write + `Deny` statements expecting them to work, and the two policy evaluators would + remain inconsistent. + +2. **Reject trust policies that contain `Deny` at role-creation time.** Fail + fast so no one can author a `Deny` that does nothing. Removes the trap but + also removes a legitimate, expected capability, and still leaves trust-policy + evaluation less expressive than identity-policy evaluation. + +3. **Evaluate `Deny` before `Allow` (IAM order).** Scan for a matching explicit + `Deny` first and block assumption if one is found; only then look for a + matching `Allow`. Matches IAM and the existing identity-policy evaluator. + +## Decision + +Trust-policy evaluation follows IAM order: a matching explicit `Deny` blocks +role assumption and is evaluated before any `Allow` (Option 3). With no matching +`Deny`, a matching `Allow` grants assumption; with neither, assumption is +implicitly denied. + +## Rationale + +- **Consistency with the identity-policy evaluator.** `policy/evaluator.rs` + already does explicit-deny-first across identity policies, permissions + boundaries, and session policies. Trust policies evaluating `Deny` differently + was an inconsistency a reader would have to discover by accident. +- **Principle of least astonishment / least privilege.** A trust policy that + says `Deny` must deny. Silently ignoring it fails safe only by luck (when + there is no broad `Allow`) and fails open in exactly the case an author writes + a `Deny` to guard against. +- **Matches the semantics callers already know.** ExtendDB speaks the DynamoDB / + IAM wire contract; explicit-deny-wins is the IAM evaluation rule operators + expect. +- **Not an auth-model change.** This corrects behavior to the documented and + already-implemented-elsewhere model. It does not introduce a new policy + language, condition key, or evaluation phase, so it is a bug fix rather than an + RFC-level change to the auth model. + +## Consequences + +- **Easier:** trust-policy authors can rely on `Deny`, and the two policy + evaluators now share one mental model (explicit deny wins). +- **Behavioral change to be aware of:** a trust policy that already contained a + matching `Deny` alongside a broad `Allow` previously granted assumption and + will now block it. This is the intended fix, but it is a behavior change for + any deployment that (perhaps unknowingly) relied on `Deny` being ignored. There + is no change for trust policies that contain only `Allow` statements. +- **Upgrade impact for existing deployments:** two further changes affect + deployments upgrading from a build that ignored `Deny`. First, passing session + tags on an `AssumeRole` call now exercises `sts:TagSession`, so a role whose + trust policy allows only `sts:AssumeRole` denies a tagged call that previously + succeeded; allow `sts:TagSession` in the trust policy to keep passing tags. + Second, `create_role` now validates the trust document the way IAM does, so + automation that sent a document IAM rejects (a `Resource` field, `sts:*`, a + bare `"*"` principal, a missing `Principal`, and so on) starts getting a 400 + at role creation. Existing stored roles are never re-validated, so no stored + role is locked out at evaluation. +- **Condition-key resolution.** A condition key falls into one of three cases, + in both trust-policy and identity-policy evaluation. (1) The context resolves + the key to a value: evaluate normally. (2) The context resolves it as absent + the same way the service would: IAM absent-key semantics apply, including that + a negated operator on an absent single-valued key matches. (3) A documented + global key the service populates for one of ExtendDB's callers but the context + cannot resolve: the statement fails closed whatever the operator, so a `Deny` + blocks and an `Allow` does not grant, rather than the key being treated as + absent (which would let a negated operator spuriously match). ExtendDB resolves + every global key the service populates for a signed IAM-user or assumed-role + request, or an `AssumeRole` call, so case 3 is empty: the resolved set is + `aws:SecureTransport` (listener TLS state), `aws:RequestedRegion` (server + region), `aws:CurrentTime` and `aws:EpochTime` (request time), + `aws:PrincipalType`, `aws:username` (user caller), `aws:userid` + (`AIDA...` for a user, `:` for a role session), + `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:ResourceAccount`, + `aws:SourceIp` (connection peer address, evaluated with `IpAddress` and + `NotIpAddress` as IPv4 and IPv6 CIDR membership), `aws:UserAgent` (the + `User-Agent` header), `aws:ViaAWSService` and `aws:PrincipalIsAWSService` + (both false for a direct caller), the tag keys (`aws:PrincipalTag/*`, + `aws:RequestTag/*`, `aws:ResourceTag/*` on the identity path, + `dynamodb:ResourceTag/*`, each matched case-insensitively on the key), + `sts:ExternalId` and `aws:TagKeys`, and, for a role-session caller only, + `aws:TokenIssueTime` (the session issue time) and `aws:MultiFactorAuthPresent` + (`false` for a session assumed without MFA), both absent for an IAM user. + Every other `aws:` global key is absent, + which is what the service does for a key it does not populate for this caller + and for a key it does not recognize: the organization keys + (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, and the resource and source + organization keys), because an ExtendDB account is a standalone AWS account + where IAM leaves them unset; the VPC-endpoint, federation, service-principal + and cross-service keys, and `aws:MultiFactorAuthAge`, which the service does + not populate for a direct IAM-user or assumed-role caller over a public + endpoint; and any + unknown or future `aws:` key. The evaluator keeps an explicit (currently + empty) fail-closed list, so a future global key the service begins populating + before ExtendDB resolves it can be made to fail closed by listing it, rather + than silently granting through a negated operator. `aws:SourceIp` and + `aws:UserAgent` come from the connection and the `User-Agent` header; behind a + proxy the peer address is used and `X-Forwarded-For` is never trusted. The + full classified key list is in the admin guide. +- **Follow-up:** the decision logic was factored into a store-free + `trust_decision` helper (with `action_permitted`, `deny_statement_blocks`, + `allow_statement_grants` and `effect_of`) so the + Deny-over-Allow ordering is unit-tested directly. The decision is made per + exercised STS action: `sts:AssumeRole`, plus `sts:TagSession` when the call + passes session tags (ExtendDB does not implement source identity, so + `sts:SetSourceIdentity` is never exercised). `Effect` is compared + case-insensitively, `Action`/`NotAction` are matched, and account principals + (`{"AWS": ":root"}` and a bare account id) match any caller in the + account. A statement whose `Condition` block fails to parse is handled + asymmetrically: an unparseable `Allow` does not grant, and an unparseable + `Deny` fails closed, so it blocks rather than being silently dropped. A + malformed `Deny` therefore cannot grant assumption. +- **Write-time validation:** trust documents are validated when a role is + created, so a document IAM would reject (a missing or invalid `Effect`, + `NotPrincipal`, a missing `Principal`, a bare-string or wildcard-ARN + principal, a missing `Action`, an action outside the STS trust set, a + `Resource` field, or an unknown or malformed `Condition`) is not stored and + then dropped at evaluation. Documents IAM accepts (a missing `Version`, a + single-object `Statement`, numeric condition values, unknown condition keys) + are accepted. +- **Commitment:** trust-policy and identity-policy evaluation should stay aligned + on evaluation order; a future change to one should consider the other. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3b06a97b1..2deec1259 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -47,4 +47,5 @@ decision, write a new ADR. | [0018](0018-cassandra-stream-implementation.md) | Cassandra: DynamoDB Streams implementation | Accepted | | [0019](0019-cassandra-logical-backup-restore.md) | Cassandra: logical backup and restore | Accepted | | [0020](0020-cassandra-occ-update-item.md) | Cassandra: optimistic concurrency control for UpdateItem and PutItem | Accepted | -| [0021](0021-cassandra-lwt-delete-atomicity-gap.md) | Cassandra: LWT delete atomicity gap and required worker/saga pattern | Accepted | \ No newline at end of file +| [0021](0021-cassandra-lwt-delete-atomicity-gap.md) | Cassandra: LWT delete atomicity gap and required worker/saga pattern | Accepted | +| [0022](0022-trust-policy-deny-over-allow.md) | Role trust-policy evaluation follows IAM Deny-over-Allow order | Accepted | \ No newline at end of file diff --git a/docs/design/05-component-auth-v3.md b/docs/design/05-component-auth-v3.md index cbac69f91..341d2e0b8 100755 --- a/docs/design/05-component-auth-v3.md +++ b/docs/design/05-component-auth-v3.md @@ -391,7 +391,11 @@ extenddb manage --user admin --password assume-role \ Flow: 1. Load role, extract trust policy -2. Evaluate trust policy: caller ARN must match a Principal in an Allow statement, conditions must pass +2. Evaluate the trust policy in IAM order: a matching explicit Deny blocks + assumption, otherwise a matching Allow grants it, decided per exercised STS + action (sts:AssumeRole, plus sts:TagSession when session tags are passed). + Principal, Action/NotAction, and Condition must match; a Deny whose Condition + does not parse fails closed. 3. Generate temporary credentials (ASIA* access key + secret + session token) 4. Merge tags: role tags + session tags (session wins on conflict) 5. Store session in `iam_sessions` diff --git a/docs/design/05-component-auth.md b/docs/design/05-component-auth.md index fd2224363..fa5bfb10c 100755 --- a/docs/design/05-component-auth.md +++ b/docs/design/05-component-auth.md @@ -867,9 +867,14 @@ extenddb provides a local AssumeRole mechanism for testing role-based access pat 5. Build AssumeRoleContext from caller tags + SessionTags + ExternalId. -6. Evaluate trust policy: caller ARN must match a Principal in an Allow - statement, and all Condition blocks must pass against AssumeRoleContext. - If denied, return 403 AccessDenied. +6. Evaluate the trust policy in IAM order. The call exercises `sts:AssumeRole`, + and `sts:TagSession` as well when session tags are passed. For each + exercised action a matching explicit `Deny` blocks the call, and otherwise a + matching `Allow` grants it; with neither, assumption is implicitly denied. A + statement matches when its `Principal` matches the caller, its `Action` (or + `NotAction`) covers the action, and all `Condition` blocks pass against + AssumeRoleContext. A `Deny` whose `Condition` fails to parse fails closed (it + blocks). If the call is not granted, return 403 AccessDenied. 7. Generate temporary credentials: - New access_key_id (prefixed "ASIA" to match AWS convention) @@ -904,7 +909,23 @@ A role's trust policy controls who can assume it: } ``` -The trust policy is evaluated when AssumeRole is called. Only the `Principal` and `Condition` fields are checked — `Action` must be `sts:AssumeRole` and `Resource` is implicit (the role itself). +The trust policy is evaluated when AssumeRole is called, following IAM order: a +matching explicit `Deny` blocks assumption before any `Allow` is considered, and +the decision is made per exercised STS action (`sts:AssumeRole`, plus +`sts:TagSession` when session tags are passed). A statement matches on +`Principal`, on `Action`/`NotAction`, and on its `Condition` block. `Effect` is +compared case-insensitively. The `Resource` field is not used (the role itself +is implicit) and is rejected at write. + +`Principal` matching covers a single ARN, a list of ARNs, `{"AWS": "*"}`, a bare +account id, and an account root ARN (`arn:aws:iam:::root`); the last +two match any caller in that account. `NotPrincipal` is rejected at write. A +`Deny` whose `Condition` references an operator or key the evaluator does not +resolve fails closed, so it blocks rather than being silently dropped. Trust +documents are validated when the role is created: the documents IAM rejects for +a trust policy are rejected, and those it accepts (a missing `Version`, a +single-object `Statement`, numeric condition values, unknown condition keys) are +accepted. > **Note:** Trust policies use `"Action": "sts:AssumeRole"` for compatibility with real IAM policy documents, even though extenddb exposes AssumeRole via `/management/assume-role` rather than an STS endpoint. The policy engine matches this action string during trust policy evaluation regardless of the actual HTTP endpoint used. diff --git a/docs/design/12-auth-authz-cache.md b/docs/design/12-auth-authz-cache.md index 61b1e56a4..93b8f1874 100644 --- a/docs/design/12-auth-authz-cache.md +++ b/docs/design/12-auth-authz-cache.md @@ -341,7 +341,9 @@ auth, admin user). The response shape is: "role_boundary": { … }, "role_tags": { … }, "session_data": { … }, - "resource_tags": { … } + "resource_tags": { … }, + "user_created_at": { … }, + "role_created_at": { … } } } ``` diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md index 90fe53f89..f3191d21c 100755 --- a/docs/manuals/05-admin-guide.md +++ b/docs/manuals/05-admin-guide.md @@ -124,7 +124,7 @@ In-memory stale-while-revalidate caches eliminate the per-request catalog roundt | `negative_ttl_seconds` | `5` | Negative-cache TTL for "not found" results. | | `max_entries` | `10000` | Per-cache LRU cap. | -Statistics are exposed at `/management/auth-cache-metrics` (JSON, admin-authenticated). The per-cache `pass_through` flag distinguishes "cache disabled" from "cache cold." +Statistics are exposed at `/management/auth-cache-metrics` (JSON, admin-authenticated). The per-cache `pass_through` flag distinguishes "cache disabled" from "cache cold." The report includes every authorization sub-cache, among them the two principal creation-time caches (`user_created_at`, `role_created_at`) consulted on each user and role-session request to derive `aws:userid` and `aws:TokenIssueTime`. **Invalidation timing**: @@ -323,6 +323,62 @@ Access key prefixes: `AKIAEXTENDDB` (long-lived), `ASIAEXTENDDB` (temporary/Assu delete-role --account-id 123456789012 --role-name data-reader ``` +#### What a trust-policy Deny matches + +A role's trust policy decides who may assume the role. It is evaluated in IAM +order: a matching explicit `Deny` blocks assumption before any `Allow` is +considered, and with no matching `Allow` assumption is implicitly denied. The +decision is made for each STS action the call exercises: `sts:AssumeRole` +always, and `sts:TagSession` as well when the call passes session tags. Passing +session tags to a role whose trust policy does not allow `sts:TagSession` is +denied. ExtendDB does not implement source identity, so `sts:SetSourceIdentity` +is never part of the decision. + +A statement matches when its `Principal` matches the caller, its `Action` (or +`NotAction`) covers the action, and all `Condition` blocks pass. `Effect` is +compared case-insensitively (`"deny"` works). A `Principal` matches on an exact +ARN, a list of ARNs, `{"AWS": "*"}`, a bare account id, or an account root ARN +(`arn:aws:iam:::root`); the last two match every caller in that +account, for both `Allow` and `Deny`. + +Principal ARNs are matched case-sensitively: a `Deny` naming `user/Alice` does +not block a caller asserted as `user/alice`. Assume authorizes on the trust +policy alone. The endpoint is admin-only and the admin asserts the caller ARN; +unlike IAM, ExtendDB does not also evaluate the caller's identity policy for +`sts:AssumeRole`, so a role that trusts an account root grants assumption to an +in-account caller on the strength of the trust policy by itself. + +What a trust-policy `Deny` does not do: + +- A statement whose `Condition` uses an operator ExtendDB does not implement + fails closed: a `Deny` blocks assumption and an `Allow` grants nothing, rather + than the operator being ignored. ExtendDB resolves every global key STS + populates for an `AssumeRole` call, so no documented key fails closed: + `aws:PrincipalTag/` (tag key matched case-insensitively), `sts:ExternalId`, + `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:ResourceAccount`, + `aws:RequestTag/`, `aws:TagKeys` (from the passed session tags), + `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, + `aws:PrincipalType`, `aws:username` (for a user caller), `aws:userid` + (`AIDA...` for a user, `:` for a role session), + `aws:SourceIp` (the `AssumeRole` connection peer address), `aws:UserAgent` (the + `User-Agent` header), `aws:ViaAWSService` and `aws:PrincipalIsAWSService`. The + organization keys (`aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`) and every + other `aws:` global key the service does not populate for a direct caller (the + VPC, federation, MFA and source-service keys) are absent, as on a standalone + AWS account, and keep absent-key semantics. `IpAddress` and `NotIpAddress` are + evaluated as IPv4 and IPv6 CIDR membership; `BinaryEquals` is accepted at role + creation (IAM accepts it) but is not evaluated, so a statement using it fails + closed. See the classified key list below. +- `NotPrincipal` is not allowed in a trust policy and is rejected when the role + is created. +- A `Resource` field is not allowed in a trust policy and is rejected at create. +- An `Action` outside the STS trust set (`sts:AssumeRole`, `sts:TagSession`, + `sts:SetSourceIdentity`), including `sts:*`, is rejected at create. +- A bare `"*"` principal string, or a wildcard inside a principal ARN + (`user/dev-*`), is rejected at create. A principal naming a user or role that + does not exist is accepted; the statement simply matches no caller (an `Allow` + on it grants nothing, a `Deny` on it blocks no one). + ### Policies Inline policies can be attached to users, groups, and roles: @@ -371,6 +427,107 @@ Inline policies can be attached to users, groups, and roles: }' ``` +#### Condition keys in identity policies + +Identity-policy conditions on DynamoDB requests follow the same rule as trust +policies. A condition key the request resolves is evaluated normally. A key that +is absent follows IAM absent-key semantics, including that a negated operator +(`StringNotEquals`, `StringNotLike`, `ArnNotEquals`, `NotIpAddress`) on an +absent single-valued key matches, so a `Deny` gated on one applies when the +caller omits the key. ExtendDB resolves `aws:PrincipalArn`, `aws:PrincipalAccount`, +`aws:ResourceAccount`, `aws:PrincipalType`, `aws:username` (for a user caller), +`aws:userid`, `aws:SourceIp`, `aws:UserAgent`, `aws:SecureTransport`, +`aws:RequestedRegion`, `aws:CurrentTime`, `aws:EpochTime`, `aws:ViaAWSService`, +`aws:PrincipalIsAWSService`, `aws:PrincipalTag/`, `aws:ResourceTag/` +and `dynamodb:ResourceTag/` (tag keys matched case-insensitively) on a +data-plane request. Every other `aws:` global key is absent, which is what the +service does for a key it does not populate for this caller and for an unknown +key. + +#### Global condition key resolution + +The complete list of AWS global condition keys, classified by how ExtendDB +resolves each for its callers (a signed IAM-user or assumed-role DynamoDB +request, or an `AssumeRole` call). ExtendDB behaves as a standalone AWS account +reached directly over its endpoint. + +Resolved (evaluated against the request value): + +- `aws:PrincipalArn`, `aws:PrincipalAccount`, `aws:PrincipalType`, + `aws:username` (user caller only), `aws:userid`. +- `aws:ResourceAccount`. +- `aws:SourceIp` (connection peer address; `IpAddress` and `NotIpAddress` do + IPv4 and IPv6 CIDR membership). This is the address of the TCP connection the + request arrived on. Behind a reverse proxy or load balancer that is the + proxy's address for every caller, and ExtendDB does not read `X-Forwarded-For` + (the header is caller-supplied and spoofable), so an `aws:SourceIp` condition + is meaningful only when clients connect to ExtendDB directly. +- `aws:UserAgent` (`User-Agent` header). +- `aws:SecureTransport`, `aws:RequestedRegion`, `aws:CurrentTime`, + `aws:EpochTime`. +- `aws:ViaAWSService` and `aws:PrincipalIsAWSService` (both false for a direct + caller). +- `aws:PrincipalTag/`, `aws:RequestTag/` (trust path), + `aws:ResourceTag/`, `dynamodb:ResourceTag/`, `aws:TagKeys`, + `sts:ExternalId` (trust path). +- Role-session only (absent for an IAM user, matching the service): + `aws:TokenIssueTime` (the session issue time) and + `aws:MultiFactorAuthPresent` (`false` for a session assumed without MFA). + +Absent (keep IAM absent-key semantics, as the service leaves them unset for +this caller): + +- Organization keys: `aws:PrincipalOrgID`, `aws:PrincipalOrgPaths`, + `aws:ResourceOrgID`, `aws:ResourceOrgPaths`, `aws:SourceOrgID`, + `aws:SourceOrgPaths` (a standalone account has no organization). An ExtendDB + account belongs to no organization, so a condition on an organization key + behaves as it does for a standalone AWS account: the key is absent, a `Deny` + that tests `aws:PrincipalOrgID` with a positive operator does not match (and + so does not block), and a negated operator on it matches. Do not carry an + organization-scoped `Deny` over from AWS expecting it to block on ExtendDB. +- Role-session and federation keys ExtendDB does not model: + `aws:MultiFactorAuthAge`, `aws:FederatedProvider`, + `aws:SourceIdentity`, `aws:AssumedRoot`, `aws:SignInSessionArn`. + (`aws:TokenIssueTime` and `aws:MultiFactorAuthPresent` are resolved for a + role session; see the resolved list above.) +- Network keys for VPC endpoints: `aws:SourceVpc`, `aws:SourceVpcArn`, + `aws:SourceVpce`, `aws:VpcSourceIp`, `aws:VpceAccount`, `aws:VpceOrgID`, + `aws:VpceOrgPaths`, `aws:Ec2InstanceSourceVpc`, + `aws:Ec2InstanceSourcePrivateIPv4`. +- Service-principal and cross-service keys: `aws:PrincipalServiceName`, + `aws:PrincipalServiceNamesList`, `aws:SourceAccount`, `aws:SourceArn`, + `aws:CalledVia`, `aws:CalledViaFirst`, `aws:CalledViaLast`, `aws:referer`, + and the service-prefixed keys (`ec2:`, `glue:`, `codebuild:`, `lambda:`, + `ssm:`, `identitystore:`). +- Any `aws:` key AWS defines that is not in the resolved set, and any unknown + `aws:` key, which the service also treats as absent. + +Fail closed (a statement using one does not evaluate): none. The resolved and +absent sets cover every documented global key for ExtendDB's callers. The +evaluator keeps an explicit, currently empty, fail-closed list so a future +global key the service begins populating before ExtendDB resolves it can be +added rather than silently granting through a negated operator. An operator the +evaluator does not implement (for example `BinaryEquals`) still fails closed. + +`aws:TokenIssueTime` is populated by the service for a role session and absent +for an IAM user. ExtendDB resolves it for a role session from the session issue +time and leaves it absent for an IAM user, matching the service. A policy that +gates on its presence or compares it with a `Date` operator behaves as it does +on AWS for a role session. This resolution is on the DynamoDB request path; in a +trust policy evaluated during `AssumeRole` the key is absent, as it is on the +service for an `AssumeRole` caller using long-term credentials. + +`aws:MultiFactorAuthPresent` is populated by the service for a role session +(`false` for a session assumed without MFA) and absent for an IAM user with +long-term keys. ExtendDB resolves it to `false` for a role session and leaves it +absent for an IAM user, so the common deny-without-MFA guard +(`Deny if Bool aws:MultiFactorAuthPresent=false`) applies to a role session and +is inert for an IAM user, matching the service. `aws:MultiFactorAuthAge` stays +absent, as the service leaves it unset for a non-MFA session. Like +`aws:TokenIssueTime`, `aws:MultiFactorAuthPresent` is resolved on the DynamoDB +request path only; in a trust policy it is absent, as on the service for an +`AssumeRole` caller using long-term credentials. + ### Permissions Boundaries ```bash diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md index fe2bf5eb2..bd98e8b7a 100755 --- a/docs/manuals/07-upgrade-manual.md +++ b/docs/manuals/07-upgrade-manual.md @@ -250,6 +250,100 @@ The same applies to `dynamodb:LeadingKeys`. Audit existing policies with `extenddb manage list-user-policies` / `list-role-policies` and update any that use bare operators on these keys. No catalog migration is required for this change. +### IAM: `aws:TokenIssueTime` on MongoDB role sessions created before the upgrade + +**What changed.** ExtendDB now resolves `aws:TokenIssueTime` for a role session from +the session's recorded creation time. On the MongoDB backend the session store did not +record a creation time before this release. + +**Who is affected.** MongoDB deployments only. A role session established before the +upgrade has no stored creation time, so `aws:TokenIssueTime` is absent for that session +until it expires and is re-established. The three SQL and the Cassandra backends already +recorded the session creation time and are not affected. + +**Impact.** The direction is over-deny only and self-heals: a policy that gates on +`aws:TokenIssueTime` (a presence check or a `Date` comparison) denies a pre-upgrade +MongoDB session that it would grant, and resolves correctly once the session is +re-established. No catalog migration is required; new sessions record the creation time +automatically. + +### IAM: a malformed CIDR in an identity policy is rejected at write + +**What changed.** A condition value for `IpAddress` or `NotIpAddress` that is not a +valid CIDR or address (for example `10.0.0.0/33`, `999.0.0.0/8`, `not-a-cidr`, +`10.0.0.0/`) is now rejected when an identity policy or a permissions boundary is +written, with a `MalformedPolicyDocument`-class error, matching the service. Before +this release the value was accepted and the statement silently did nothing (an +`IpAddress` or `NotIpAddress` `Deny` never blocked). + +**Who is affected.** Any deployment that stored an identity policy or permissions +boundary with a malformed CIDR. New writes with a malformed value now fail. A policy +already stored with a malformed value evaluates fail closed: it no longer parses, so the +request is denied rather than the statement being ignored. Audit stored policies with +`extenddb manage list-user-policies` / `list-role-policies` and correct any malformed CIDR +before upgrading so a legitimate request is not denied. + +**What a stored malformed-CIDR policy does after upgrade.** The failure is +whole-document and scoped to the principal that owns the policy. Every request by +that principal is denied until the policy is corrected, including a request that a +different, valid policy of the same principal would allow (the malformed document +fails the request closed before the valid policy can grant). The caller receives +`AccessDeniedException` with the message "Not authorized to perform this action +(policy evaluation error)", not a server error: a parse failure on the authorization +path is mapped to a deny, not to `InternalServerError`. A different principal whose +policies are all well formed is unaffected. + +**Trust policies are unchanged.** The live service accepts a malformed CIDR in a trust +policy at write and drops the statement at evaluation; ExtendDB matches this, so a trust +policy is not rejected for a malformed CIDR. No catalog migration is required. + +### IAM: condition key names and tag keys, and more CIDR forms + +**Condition key names and tag keys fold under Unicode on the request path.** A +stored identity policy whose condition key name or tag key contains a non-ASCII +letter that folds to a resolvable key (for example the long s folding to `s`, the +st ligature to `st`, or the sharp s to `ss`) now resolves that key on a DynamoDB +request, matching the service. For such a stored policy a `Deny` gated on the +Unicode-spelled key now applies where it previously did not, and a negated `Allow` +gated on it no longer grants. A policy using only ASCII key names is unaffected. +The trust (`AssumeRole`) path matches key names and tag keys ASCII-case only and +does not fold, as the service does; a trust policy whose condition key name or tag +key contains a non-ASCII letter has that key read as absent on the trust path. + +**Tag keys that fold to the same string resolve deterministically.** If a +principal carries two tag keys that fold to the same string (for example `mass` +and `ma\u{00df}`), `aws:PrincipalTag/` now resolves to the byte-exact stored +key, then to the smallest fold-matching key by code point, every time, rather +than at random. A policy naming one of the keys exactly is unaffected. + +**Tag write rules match the service.** `TagUser`, `TagRole` and `AssumeRole` +session tags now reject a tag key or value outside IAM's grammar +`[\p{L}\p{Z}\p{N}_.:/=+\-@]`: any Unicode Letter (Lu, Ll, Lt, Lm, Lo), Number +(Nd, Nl, No) or Separator (Zs, Zl, Zp) by general category, plus the listed +symbols. A combining mark (such as U+0345, category Mn), `#`, `!` and other +punctuation, a key over 128 or value over 256 characters, the reserved `aws:` +key prefix, and two keys that differ only in ASCII case are all rejected. Only +the tags named in a given request are validated, so a tag already stored that +the new rule would reject stays in place and keeps evaluating, and does not +block setting other tags on the same principal; it is rejected only when a later +request includes it again. Live IAM behaves the same way: a tag request +validates only its own tags. + +**Policy Sid charset.** A policy `Sid` is now accepted only when it is empty or +entirely ASCII letters and digits, on both the trust write (`CreateRole`) and +the identity-policy write (`PutUserPolicy`), matching the service, which rejects +any other `Sid` (a hyphen, underscore, space, dot or non-ASCII character) with +`MalformedPolicyDocument`. The `Sid` is a statement label and does not affect +evaluation, so a policy already stored with a non-conforming `Sid` keeps +evaluating unchanged; only a new write is checked. + +**More CIDR forms.** `IpAddress`/`NotIpAddress` now accept a leading-zero octet +and read it as decimal (`010.0.0.0/8` is `10.0.0.0/8`, not octal), matching the +service, and reject an IPv4-mapped IPv6 literal (`::ffff:a.b.c.d`) at the identity +and boundary write (the service rejects it; accepting it would store an +`IpAddress` `Deny` that silently never matched). No catalog migration is required +for any of these changes. + --- ## License diff --git a/tests/management_helpers.py b/tests/management_helpers.py index 90fa23391..865dbf842 100755 --- a/tests/management_helpers.py +++ b/tests/management_helpers.py @@ -189,6 +189,7 @@ def assume_role( session_tags: dict | None = None, session_policy: dict | None = None, duration_seconds: int = 3600, + external_id: str | None = None, ) -> requests.Response: body: dict[str, Any] = { "caller_arn": caller_arn, @@ -199,6 +200,8 @@ def assume_role( body["session_tags"] = session_tags if session_policy is not None: body["session_policy"] = session_policy + if external_id is not None: + body["external_id"] = external_id return requests.post( f"{self.base_url}/accounts/{account_id}/roles/{role_name}/assume", json=body, diff --git a/tests/test_abac.py b/tests/test_abac.py index d854ea45b..62f4d98ef 100755 --- a/tests/test_abac.py +++ b/tests/test_abac.py @@ -22,6 +22,8 @@ from __future__ import annotations +import base64 +import hashlib import os import uuid from typing import Any @@ -351,7 +353,7 @@ def _setup_role_with_session_tag_policy(self, user, role): "Statement": [{ "Effect": "Allow", "Principal": {"AWS": caller_arn}, - "Action": "sts:AssumeRole", + "Action": ["sts:AssumeRole", "sts:TagSession"], }], } resp = self.mgmt.create_role(self.account_id, role, trust_policy) @@ -436,3 +438,835 @@ def test_session_tag_denies_non_matching_role(self): finally: self.mgmt.delete_user(self.account_id, user) self.mgmt.delete_role(self.account_id, role) + + +class TestOrganizationKeysAbsent: + """Identity policies gated on an organization key (for example + aws:PrincipalOrgID). An ExtendDB account is a standalone AWS account, so + IAM leaves these keys absent and absent-key semantics apply: a positive + operator does not match, and a negated operator on an absent single-valued + key matches. A key ExtendDB resolves (aws:PrincipalArn) evaluates normally. + """ + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + + def _admin_table(self) -> tuple[Any, str]: + admin = f"ur-adm-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, admin) + resp = self.mgmt.put_user_policy( + self.account_id, admin, "full", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + table = f"urk-{uuid.uuid4().hex[:8]}" + client.create_table( + TableName=table, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(client, table) + client.put_item(TableName=table, Item={"pk": {"S": "k1"}, "data": {"S": "v1"}}) + return client, table + + def test_deny_on_absent_orgid_does_not_block(self): + admin_client, table = self._admin_table() + user = f"urk-deny-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-example1"}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + # An ExtendDB account is a standalone AWS account, so + # aws:PrincipalOrgID is absent; StringEquals on an absent key does + # not match, so the Deny is inert and GetItem is allowed. Matches + # live IAM for a standalone account. + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) + + def test_negated_allow_on_absent_orgid_grants(self): + admin_client, table = self._admin_table() + user = f"urk-neg-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + # Org key absent for a standalone account; a negated operator on an + # absent single-valued key matches, so the Allow grants. Matches live + # IAM for a standalone account. + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gated_on_resolved_principal_arn_grants(self): + admin_client, table = self._admin_table() + user = f"urk-arn-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + user_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalArn": user_arn}}}]}, + ) + assert resp.status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + out = client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + assert out["Item"]["data"]["S"] == "v1" + finally: + admin_client.delete_table(TableName=table) + self.mgmt.delete_user(self.account_id, user) + + +# --------------------------------------------------------------------------- +# Revised global-key rule: resolvable keys, service-populated keys, allowlist +# --------------------------------------------------------------------------- + +def _run_get(mgmt: ManagementClient, endpoint: str, region: str, account_id: str, + table: str, policy: dict) -> str: + """Create a fresh user with `policy`, call GetItem on `table`, return the + outcome, and clean the user up. ExtendDB serves fresh policy on write, so no + retry is needed.""" + user = f"gk-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(mgmt, account_id, user) + resp = mgmt.put_user_policy(account_id, user, "p", policy) + assert resp.status_code == 204, resp.text + client = _make_client(endpoint, ak, sk, region) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + mgmt.delete_user(account_id, user) + + +class _TaggedTableBase: + """Shared env=prod tagged table for the global-key tests.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + + @pytest.fixture + def table(self): + admin = f"gk-adm-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, admin) + resp = self.mgmt.put_user_policy( + self.account_id, admin, "full", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}]}, + ) + assert resp.status_code == 204 + admin_client = _make_client(self.endpoint, ak, sk, self.region) + name = f"gk-{uuid.uuid4().hex[:8]}" + admin_client.create_table( + TableName=name, + AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + Tags=[{"Key": "env", "Value": "prod"}], + ) + wait_for_active(admin_client, name) + admin_client.put_item(TableName=name, Item={"pk": {"S": "k1"}, "data": {"S": "v1"}}) + yield name + try: + admin_client.delete_table(TableName=name) + wait_for_deleted(admin_client, name) + except Exception: + pass + self.mgmt.delete_user(self.account_id, admin) + + def _get(self, table: str, policy: dict) -> str: + return _run_get(self.mgmt, self.endpoint, self.region, self.account_id, table, policy) + + @staticmethod + def _allow_get_if(condition: dict) -> dict: + return {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]} + + @staticmethod + def _allow_all_deny_get_if(condition: dict) -> dict: + return {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]} + + +class TestResolvableGlobalKeys(_TaggedTableBase): + """Guard statements on keys ExtendDB resolves no longer fail closed. + All four grant, matching live DynamoDB.""" + + def test_securetransport_deny_guard_allows(self, table): + # Deny if not over TLS; the request is over TLS so the deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:SecureTransport": "false"}})) == "ALLOW" + + def test_requestedregion_deny_guard_allows(self, table): + # Deny if region is not the server region; it is, so the deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"StringNotEquals": {"aws:RequestedRegion": self.region}})) == "ALLOW" + + def test_principaltype_user_allows(self, table): + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:PrincipalType": "User"}})) == "ALLOW" + + def test_currenttime_allows(self, table): + assert self._get(table, self._allow_get_if( + {"DateGreaterThan": {"aws:CurrentTime": "2020-01-01T00:00:00Z"}})) == "ALLOW" + + +class TestServicePopulatedGlobalKeys(_TaggedTableBase): + """The allowlist: keys the service populates evaluate as present, and + an unknown aws: key fails closed.""" + + def test_viaawsservice_negated_allow_denies(self, table): + # ViaAWSService is present and false; StringNotEquals=false is false, so + # the Allow does not grant. Live DynamoDB: DENY. + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:ViaAWSService": "false"}})) == "DENY" + + def test_principalisawsservice_negated_allow_denies(self, table): + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:PrincipalIsAWSService": "false"}})) == "DENY" + + def test_resourcetag_negated_allow_denies(self, table): + # Table tagged env=prod; StringNotEquals env=prod is false. Live: DENY. + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:ResourceTag/env": "prod"}})) == "DENY" + + def test_resourcetag_positive_allow_grants(self, table): + # aws:ResourceTag/env resolves to prod. Live: ALLOW. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:ResourceTag/env": "prod"}})) == "ALLOW" + + def test_mfa_boolifexists_allows_non_mfa_caller(self, table): + # A non-MFA caller has aws:MultiFactorAuthPresent absent; BoolIfExists + # passes when the key is absent. Live: ALLOW. + assert self._get(table, self._allow_get_if( + {"BoolIfExists": {"aws:MultiFactorAuthPresent": "false"}})) == "ALLOW" + + def test_unknown_key_negated_allow_grants(self, table): + # An unknown aws: key is absent, as on the service, so the negated + # operator matches an absent single-valued key and the Allow grants. + # Live DynamoDB: ALLOW. (Round two failed closed and denied here; this + # row changes from DENY to ALLOW under the revised rule.) + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "ALLOW" + + def test_unknown_key_deny_negated_blocks(self, table): + # A Deny gated on StringNotEquals of an unknown aws: key: the key is + # absent, the negated operator matches, so the Deny applies and blocks. + # Live DynamoDB also denies here. + assert self._get(table, self._allow_all_deny_get_if( + {"StringNotEquals": {"aws:NoSuchGlobalKey": "x"}})) == "DENY" + + +class TestRound3GlobalKeys(_TaggedTableBase): + """Round three keys on the identity path: aws:userid, aws:SourceIp, + aws:UserAgent, aws:ResourceAccount, and the organization keys absent for a + standalone account. Every expected outcome was measured live.""" + + def test_userid_present_denies_null_true(self, table): + # aws:userid is populated, so Null true is false and the Allow does not + # grant. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:userid": "true"}})) == "DENY" + + def test_userid_like_aida_grants(self, table): + # A user's aws:userid begins with AIDA. + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:userid": "AIDA*"}})) == "ALLOW" + + def test_sourceip_loopback_grants(self, table): + # The test client connects over the IPv4 loopback, so aws:SourceIp is + # 127.0.0.1 and IpAddress on the loopback grants. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) == "ALLOW" + + def test_sourceip_other_range_denies(self, table): + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}})) == "DENY" + + def test_sourceip_notipaddress_loopback_does_not_grant(self, table): + # NotIpAddress excludes the caller's own loopback address, so the Allow + # does not grant. + assert self._get(table, self._allow_get_if( + {"NotIpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) == "DENY" + + def test_useragent_stringlike_grants(self, table): + # The boto3 client's User-Agent contains Botocore. + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:UserAgent": "*Botocore*"}})) == "ALLOW" + + def test_useragent_stringlike_no_match_denies(self, table): + assert self._get(table, self._allow_get_if( + {"StringLike": {"aws:UserAgent": "*ZZZNoSuchAgentZZZ*"}})) == "DENY" + + def test_resourceaccount_eq_grants(self, table): + # aws:ResourceAccount is the table owner account. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:ResourceAccount": self.account_id}})) == "ALLOW" + + def test_resourceaccount_present_denies_null_true(self, table): + assert self._get(table, self._allow_get_if( + {"Null": {"aws:ResourceAccount": "true"}})) == "DENY" + + def test_principalorgid_absent_null_true_grants(self, table): + # Standalone account: the organization key is absent, so Null true + # grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:PrincipalOrgID": "true"}})) == "ALLOW" + + def test_principalorgid_negated_allow_grants(self, table): + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}})) == "ALLOW" + + +class TestConditionKeyNameCaseInsensitive(_TaggedTableBase): + """Condition key NAMES resolve case-insensitively on the identity path, + matching the live service. A miscased key resolves the same value as its + canonical form; a case-sensitive lookup would read it as absent and flip the + outcome.""" + + def test_principaltype_lowercase_key_allows(self, table): + # aws:principaltype resolves to User like aws:PrincipalType. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:principaltype": "User"}})) == "ALLOW" + + def test_securetransport_lowercase_key_deny_guard_allows(self, table): + # aws:securetransport resolves to true over TLS, so the Deny is inert. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:securetransport": "false"}})) == "ALLOW" + + def test_sourceip_lowercase_key_grants(self, table): + # aws:sourceip resolves like aws:SourceIp; 0.0.0.0/0 matches any caller. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:sourceip": "0.0.0.0/0"}})) == "ALLOW" + + def test_leadingkeys_lowercase_key_grants(self, table): + # dynamodb:leadingkeys resolves like dynamodb:LeadingKeys; the GetItem + # key is k1. + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {"dynamodb:leadingkeys": ["k1"]}})) == "ALLOW" + + def test_resourcetag_lowercase_prefix_grants(self, table): + # The tag prefix aws:resourcetag/ (lower) resolves like aws:ResourceTag/; + # the table is tagged env=prod. + assert self._get(table, self._allow_get_if( + {"StringEquals": {"aws:resourcetag/env": "prod"}})) == "ALLOW" + + def test_dynamodb_resourcetag_lowercase_prefix_grants(self, table): + assert self._get(table, self._allow_get_if( + {"StringEquals": {"dynamodb:resourcetag/env": "prod"}})) == "ALLOW" + + def test_principaltype_deny_lowercase_key_blocks(self, table): + # A Deny gated on a miscased key must still be enforced. aws:principaltype + # resolves to User, so the Deny applies and blocks. A case-sensitive + # lookup would read the key as absent, drop the Deny, and grant. + assert self._get(table, self._allow_all_deny_get_if( + {"StringEquals": {"aws:principaltype": "User"}})) == "DENY" + + +class TestConditionKeyNameUnicodeFold(_TaggedTableBase): + """Condition key NAMES and tag keys resolve under Unicode full case + folding on the identity path, matching the live service. A key spelled with + the long s (U+017F) or the Kelvin sign (U+212A) folds to its ASCII twin; a + case-sensitive or ASCII-only fold would read it as absent and flip the + outcome.""" + + LS = "\u017f" # long s -> s + KV = "\u212a" # Kelvin -> k + + def test_longs_securetransport_resolves(self, table): + # aws:SecureTran[s]port folds to securetransport=true; the positive gate + # grants. + assert self._get(table, self._allow_get_if( + {"Bool": {f"aws:SecureTran{self.LS}port": "true"}})) == "ALLOW" + + def test_longs_securetransport_negated_allow_does_not_overgrant(self, table): + # Allow if StringNotEquals aws:SecureTran[s]port=true. The key folds and + # resolves to true, so NotEquals is false and the Allow does not grant. + # ASCII-only folding would read the key as absent, the negated-absent + # rule would fire, and the request would be granted (the bypass). + assert self._get(table, self._allow_get_if( + {"StringNotEquals": {f"aws:SecureTran{self.LS}port": "true"}})) == "DENY" + + def test_kelvin_leadingkeys_match_grants(self, table): + # dynamodb:Leading[k]eys folds to leadingkeys=[k1]; ForAllValues against + # [k1] holds, the Allow grants. + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {f"dynamodb:Leading{self.KV}eys": ["k1"]}})) == "ALLOW" + + def test_kelvin_leadingkeys_nonmatch_does_not_overgrant(self, table): + # Allow if ForAllValues:StringEquals dynamodb:Leading[k]eys=[zzz] on + # GetItem pk=k1. The key folds to leadingkeys=[k1], which is not zzz, so + # ForAllValues fails and the Allow does not grant. ASCII-only folding + # would read the key absent, ForAllValues would be vacuously true, and + # the request would be granted (the bypass). + assert self._get(table, self._allow_get_if( + {"ForAllValues:StringEquals": {f"dynamodb:Leading{self.KV}eys": ["zzz"]}})) == "DENY" + + +class TestLeadingZeroCidr(_TaggedTableBase): + """A leading-zero octet in an IpAddress CIDR is accepted at write and + read as decimal at evaluation, matching the live service (072 is 72, not + octal 58; 0110 is 110). The test client connects from 127.0.0.1.""" + + def test_leading_zero_octet_accepted_and_matches_decimal(self, table): + # 0127.0.0.0/8 is the 127.0.0.0/8 network (decimal); the loopback caller + # is in it, so the gate grants. Before the fix the write was rejected. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "0127.0.0.0/8"}})) == "ALLOW" + + def test_leading_zero_octet_not_octal(self, table): + # 0177.0.0.0/8 read as decimal is 177.0.0.0/8, which does not contain the + # loopback caller; octal 0177 would be 127 and would match. The service + # reads decimal, so the gate does not grant. + assert self._get(table, self._allow_get_if( + {"IpAddress": {"aws:SourceIp": "0177.0.0.0/8"}})) == "DENY" + + +class TestTagCollisionDeterministic(_TaggedTableBase): + """Two stored tag keys that fold to the same string (mass and ma\u00df) + must resolve deterministically to the exact key, every time, not at random + HashMap order.""" + + SHARP = "\u00df" + + CHECKS = 20 + + def _resolve_once(self, table, policy_key): + """Create a FRESH user tagged {mass:A, ma\u00df:B}, allow GetItem when + aws:PrincipalTag/ == A, and return "ALLOW" or "DENY". + + A fresh user per check is the point: the principal's tag map is cached + per user, so one user sees a single HashMap order and hides the + nondeterminism. Each fresh user gets its own order, so resolution that + is not pinned to the exact (then smallest-by-code-point) key shows up as + a DENY on some fraction of users. + """ + user = f"gk-coll-{uuid.uuid4().hex[:8]}" + ak, sk = _create_user_with_key(self.mgmt, self.account_id, user) + try: + assert self.mgmt.tag_user( + self.account_id, user, {"mass": "A", f"ma{self.SHARP}": "B"} + ).status_code in (200, 201, 204) + assert self.mgmt.put_user_policy(self.account_id, user, "p", { + "Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": { + f"aws:PrincipalTag/{policy_key}": "A"}}}]}).status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException" + return "DENY" + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_fold_colliding_exact_key_resolves_deterministically(self, table): + # aws:PrincipalTag/mass must resolve the byte-exact stored key (mass=A), + # so every one of CHECKS fresh users allows. + results = [self._resolve_once(table, "mass") for _ in range(self.CHECKS)] + assert set(results) == {"ALLOW"}, results + + def test_fold_colliding_no_exact_key_resolves_deterministically(self, table): + # aws:PrincipalTag/MASS has no byte-exact key; it folds to "mass", + # colliding with mass and ma\u00df. The rule picks the smallest + # fold-matching key by code point (mass < ma\u00df), so A resolves and + # every one of CHECKS fresh users allows. + results = [self._resolve_once(table, "MASS") for _ in range(self.CHECKS)] + assert set(results) == {"ALLOW"}, results + + def test_rejects_invalid_and_dup_tag_keys_at_write(self): + # A combining-mark tag key and the reserved aws: prefix are rejected + # at write (ValidationError on the service); two keys differing only + # in ASCII case are a duplicate. + user = f"gk-tv-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + assert self.mgmt.tag_user(self.account_id, user, {"Equipe\u0301": "x"}).status_code == 400 + assert self.mgmt.tag_user(self.account_id, user, {"aws:team": "x"}).status_code == 400 + assert self.mgmt.tag_user(self.account_id, user, {"Team": "1", "team": "2"}).status_code == 400 + # a valid tag is accepted + assert self.mgmt.tag_user(self.account_id, user, {"team": "prod"}).status_code in (200, 201, 204) + finally: + self.mgmt.delete_user(self.account_id, user) + + +# --------------------------------------------------------------------------- +# aws:TokenIssueTime (role sessions) and aws:userid across recreate +# --------------------------------------------------------------------------- + +# The service id derivation, reproduced here so a policy can be pinned to the +# exact id of a principal's first incarnation. It hashes the principal type, +# account, name and creation time, then base32-encodes the first 17 characters, +# with an AIDA (user) or AROA (role) prefix. The creation time is read verbatim +# from the management list response, the same RFC 3339 string the server hashes. +_B32_LEN = 17 + + +def _id_body(parts: list[str]) -> str: + h = hashlib.sha256() + for p in parts: + h.update(p.encode("utf-8")) + h.update(b"\x00") + return base64.b32encode(h.digest()).decode("ascii").rstrip("=")[:_B32_LEN] + + +def _derive_user_id(account_id: str, user_name: str, created_at: str) -> str: + return "AIDA" + _id_body(["user", account_id, user_name, created_at]) + + +def _derive_role_id(account_id: str, role_name: str, created_at: str) -> str: + return "AROA" + _id_body(["role", account_id, role_name, created_at]) + + +def _user_created_at(mgmt: ManagementClient, account_id: str, user_name: str) -> str: + resp = mgmt.list_users(account_id) + assert resp.status_code == 200, resp.text + for e in resp.json(): + if e["user_name"] == user_name: + return e["created_at"] + raise AssertionError(f"user {user_name} not found in list_users") + + +def _role_created_at(mgmt: ManagementClient, account_id: str, role_name: str) -> str: + resp = mgmt.list_roles(account_id) + assert resp.status_code == 200, resp.text + for e in resp.json(): + if e["role_name"] == role_name: + return e["created_at"] + raise AssertionError(f"role {role_name} not found in list_roles") + + +class TestTokenIssueTimeRoleSession(_TaggedTableBase): + """aws:TokenIssueTime is the session issue time for a role session and + is absent for an IAM user. Measured live against STS/DynamoDB.""" + + def _role_session_get(self, table: str, condition: dict) -> str: + """Create a user and a role whose identity policy allows GetItem under + `condition`, assume the role, GetItem with the session credentials, and + return the outcome.""" + user = f"tit-caller-{uuid.uuid4().hex[:8]}" + role = f"tit-role-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_user(self.account_id, user, "TestPass123!") + assert resp.status_code == 201, resp.text + caller_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + resp = self.mgmt.put_role_policy( + self.account_id, role, "p", + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": condition}]}) + assert resp.status_code == 204, resp.text + try: + resp = self.mgmt.assume_role( + self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, user) + + def test_role_session_token_issue_time_present_denies_null_true(self, table): + # Present for a role session, so Null true is false and the Allow does + # not grant. + assert self._role_session_get( + table, {"Null": {"aws:TokenIssueTime": "true"}}) == "DENY" + + def test_role_session_token_issue_time_after_recent_past_grants(self, table): + # The session was issued just now, which is after a point an hour ago. + past = "2000-01-01T00:00:00Z" + assert self._role_session_get( + table, {"DateGreaterThan": {"aws:TokenIssueTime": past}}) == "ALLOW" + + def test_role_session_token_issue_time_before_far_future_denies(self, table): + # The session issue time is not after a point far in the future. + future = "2999-01-01T00:00:00Z" + assert self._role_session_get( + table, {"DateGreaterThan": {"aws:TokenIssueTime": future}}) == "DENY" + + def test_user_token_issue_time_absent_null_true_grants(self, table): + # An IAM user leaves aws:TokenIssueTime absent, so Null true grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:TokenIssueTime": "true"}})) == "ALLOW" + + def test_user_token_issue_time_absent_date_operator_denies(self, table): + # A positive Date operator on the absent key does not match, so the + # Allow does not grant. + assert self._get(table, self._allow_get_if( + {"DateGreaterThan": {"aws:TokenIssueTime": "2000-01-01T00:00:00Z"}})) == "DENY" + + +class TestUserIdRecreate(_TaggedTableBase): + """aws:userid changes when a principal is deleted and recreated with + the same name, so a policy pinned to the first incarnation's id denies the + second. Measured live against IAM/DynamoDB.""" + + def test_user_id_changes_on_recreate(self, table): + name = f"uidrc-{uuid.uuid4().hex[:8]}" + # First incarnation: pin a policy to its id, confirm it grants. + ak, sk = _create_user_with_key(self.mgmt, self.account_id, name) + created1 = _user_created_at(self.mgmt, self.account_id, name) + uid1 = _derive_user_id(self.account_id, name, created1) + pol = self._allow_get_if({"StringEquals": {"aws:userid": uid1}}) + assert self.mgmt.put_user_policy(self.account_id, name, "p", pol).status_code == 204 + client = _make_client(self.endpoint, ak, sk, self.region) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + first = "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + first = "DENY" + assert first == "ALLOW", "policy pinned to the first incarnation id should grant" + + # Delete and recreate with the same name: a new id. + self.mgmt.delete_user(self.account_id, name) + ak2, sk2 = _create_user_with_key(self.mgmt, self.account_id, name) + created2 = _user_created_at(self.mgmt, self.account_id, name) + assert created2 != created1, "recreated user should have a new creation time" + # Reattach the SAME policy (still pinned to uid1). + assert self.mgmt.put_user_policy(self.account_id, name, "p", pol).status_code == 204 + client2 = _make_client(self.endpoint, ak2, sk2, self.region) + try: + client2.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + second = "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + second = "DENY" + finally: + self.mgmt.delete_user(self.account_id, name) + assert second == "DENY", "recreated principal must not match the old id" + + def test_role_id_changes_on_recreate(self, table): + caller = f"ridrc-caller-{uuid.uuid4().hex[:8]}" + role = f"ridrc-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, caller, "TestPass123!").status_code == 201 + caller_arn = f"arn:aws:iam::{self.account_id}:user/{caller}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + + def assume_and_get(expected_userid: str) -> str: + pol = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"StringEquals": {"aws:userid": expected_userid}}}]} + assert self.mgmt.put_role_policy(self.account_id, role, "p", pol).status_code == 204 + resp = self.mgmt.assume_role(self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + + try: + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + created1 = _role_created_at(self.mgmt, self.account_id, role) + uid1 = _derive_role_id(self.account_id, role, created1) + ":sess1" + assert assume_and_get(uid1) == "ALLOW", "role session should match the pinned id" + + # Recreate the role with the same name: a new role id. + self.mgmt.delete_role(self.account_id, role) + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + created2 = _role_created_at(self.mgmt, self.account_id, role) + assert created2 != created1, "recreated role should have a new creation time" + # The policy is still pinned to the first incarnation id. + assert assume_and_get(uid1) == "DENY", "recreated role must not match the old id" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, caller) + + +class TestMultiFactorAuthPresentRoleSession(_TaggedTableBase): + """aws:MultiFactorAuthPresent is present and false for a role session + assumed without MFA, and absent for an IAM user. Measured live against + STS/DynamoDB (role session DENY, user ALLOW on the deny-without-MFA guard).""" + + def _role_session_get(self, table: str, policy: dict) -> str: + user = f"mfa-caller-{uuid.uuid4().hex[:8]}" + role = f"mfa-role-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + caller_arn = f"arn:aws:iam::{self.account_id}:user/{user}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": caller_arn}, + "Action": "sts:AssumeRole"}]} + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + assert self.mgmt.put_role_policy(self.account_id, role, "p", policy).status_code == 204 + try: + resp = self.mgmt.assume_role(self.account_id, role, caller_arn, "sess1") + assert resp.status_code == 201, resp.text + creds = resp.json() + client = boto3.client( + "dynamodb", endpoint_url=self.endpoint, + aws_access_key_id=creds["access_key_id"], + aws_secret_access_key=creds["secret_access_key"], + aws_session_token=creds["session_token"], + region_name=self.region, + config=BotoConfig(retries={"max_attempts": 0}), + verify=not self.endpoint.startswith("https://")) + try: + client.get_item(TableName=table, Key={"pk": {"S": "k1"}}) + return "ALLOW" + except ClientError as exc: + assert exc.response["Error"]["Code"] == "AccessDeniedException", exc.response["Error"] + return "DENY" + finally: + self.mgmt.delete_role(self.account_id, role) + self.mgmt.delete_user(self.account_id, user) + + def test_role_session_deny_without_mfa_blocks(self, table): + # aws:MultiFactorAuthPresent=false is present for a role session, so the + # Deny fires. Live: DENY. + policy = {"Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*"}, + {"Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "false"}}}]} + assert self._role_session_get(table, policy) == "DENY" + + def test_role_session_mfa_present_null_false_grants(self, table): + # The key is present for a role session, so Null false grants. + policy = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Null": {"aws:MultiFactorAuthPresent": "false"}}}]} + assert self._role_session_get(table, policy) == "ALLOW" + + def test_role_session_require_mfa_true_denies(self, table): + # The session is non-MFA (false), so a require-true guard does not grant. + policy = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}}]} + assert self._role_session_get(table, policy) == "DENY" + + def test_user_deny_without_mfa_is_inert(self, table): + # An IAM user leaves aws:MultiFactorAuthPresent absent, so the Deny on + # Bool=false is inert and the request is allowed. Live: ALLOW. + assert self._get(table, self._allow_all_deny_get_if( + {"Bool": {"aws:MultiFactorAuthPresent": "false"}})) == "ALLOW" + + def test_user_mfa_absent_null_true_grants(self, table): + # The key is absent for an IAM user, so Null true grants. + assert self._get(table, self._allow_get_if( + {"Null": {"aws:MultiFactorAuthPresent": "true"}})) == "ALLOW" + + +class TestMalformedCidrRejection: + """A malformed CIDR in an IpAddress/NotIpAddress condition is rejected + at identity-policy and permissions-boundary write, matching the live service + (MalformedPolicyDocument). The trust path accepts it (see + test_auth_integration).""" + + MALFORMED = ["10.0.0.0/33", "999.0.0.0/8", "not-a-cidr", "10.0.0.0/"] + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.mgmt = mgmt + self.account_id = account_id + + @staticmethod + def _policy(cidr, op="IpAddress"): + return {"Version": "2012-10-17", "Statement": [{ + "Effect": "Deny", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {op: {"aws:SourceIp": cidr}}}]} + + def test_put_user_policy_rejects_malformed_cidr(self): + user = f"cidr-u-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + for cidr in self.MALFORMED: + resp = self.mgmt.put_user_policy(self.account_id, user, "p", self._policy(cidr)) + assert resp.status_code == 400, f"{cidr}: {resp.status_code} {resp.text}" + # NotIpAddress too. + resp = self.mgmt.put_user_policy( + self.account_id, user, "p", self._policy("10.0.0.0/33", "NotIpAddress")) + assert resp.status_code == 400, resp.text + # A well-formed CIDR is accepted. + resp = self.mgmt.put_user_policy(self.account_id, user, "p", self._policy("10.0.0.0/8")) + assert resp.status_code == 204, resp.text + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_put_role_policy_rejects_malformed_cidr(self): + role = f"cidr-r-{uuid.uuid4().hex[:8]}" + trust = {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Principal": {"AWS": f"arn:aws:iam::{self.account_id}:root"}, + "Action": "sts:AssumeRole"}]} + assert self.mgmt.create_role(self.account_id, role, trust).status_code == 201 + try: + resp = self.mgmt.put_role_policy( + self.account_id, role, "p", self._policy("10.0.0.0/33")) + assert resp.status_code == 400, resp.text + finally: + self.mgmt.delete_role(self.account_id, role) + + def test_set_user_boundary_rejects_malformed_cidr(self): + user = f"cidr-b-{uuid.uuid4().hex[:8]}" + assert self.mgmt.create_user(self.account_id, user, "TestPass123!").status_code == 201 + try: + resp = self.mgmt.set_user_boundary( + self.account_id, user, self._policy("not-a-cidr")) + assert resp.status_code == 400, resp.text + finally: + self.mgmt.delete_user(self.account_id, user) diff --git a/tests/test_auth_integration.py b/tests/test_auth_integration.py index 6ac4f2413..b34fe0891 100755 --- a/tests/test_auth_integration.py +++ b/tests/test_auth_integration.py @@ -743,3 +743,751 @@ def test_unauthenticated_create_table_rejected(self): ) err = exc_info.value.response["Error"] assert err["Code"] in ("UnrecognizedClientException", "InvalidSignatureException", "AccessDeniedException") + + +# --------------------------------------------------------------------------- +# Trust policy Deny evaluation and write-time validation +# --------------------------------------------------------------------------- + +# Expected outcomes match live IAM and STS. The assume +# endpoint returns 201 when the trust policy grants assumption and 403 when it +# denies. create_role returns 400 when the trust document is one IAM rejects. + +ALLOW = 201 +DENY = 403 +REJECT = 400 + + +def _trust(*statements: dict) -> dict: + return {"Version": "2012-10-17", "Statement": list(statements)} + + +def _stmt(effect: str, principal, action="sts:AssumeRole", condition=None, key="Principal") -> dict: + s: dict[str, Any] = {"Effect": effect, key: principal, "Action": action} + if condition is not None: + s["Condition"] = condition + return s + + +class TestTrustPolicyDeny: + """Trust-policy Deny evaluation matches live IAM, on every backend.""" + + @pytest.fixture(autouse=True) + def setup(self, auth_env, mgmt, account_id, region): + self.endpoint = auth_env[0] + self.mgmt = mgmt + self.account_id = account_id + self.region = region + + def _caller(self, tags: dict[str, str] | None = None) -> tuple[str, str]: + user = f"bbc-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_user(self.account_id, user, "TestPass123!") + assert resp.status_code == 201, resp.text + if tags: + resp = self.mgmt.tag_user(self.account_id, user, tags) + assert resp.status_code in (200, 201, 204), resp.text + return user, f"arn:aws:iam::{self.account_id}:user/{user}" + + def _assume(self, trust: dict, caller_arn: str, **assume_kwargs) -> int: + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, f"create_role: {resp.status_code} {resp.text}" + try: + resp = self.mgmt.assume_role( + self.account_id, role, caller_arn, "bb-session", **assume_kwargs + ) + return resp.status_code + finally: + self.mgmt.delete_role(self.account_id, role) + + def test_deny_overrides_allow_both_orders(self): + user, arn = self._caller() + try: + allow_then_deny = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("Deny", {"AWS": arn})) + assert self._assume(allow_then_deny, arn) == DENY + deny_then_allow = _trust(_stmt("Deny", {"AWS": arn}), _stmt("Allow", {"AWS": "*"})) + assert self._assume(deny_then_allow, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_other_principal_does_not_block(self): + user, arn = self._caller() + other = f"arn:aws:iam::{self.account_id}:user/someone-else" + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("Deny", {"AWS": other})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_deny_matches_blocks(self): + # Exercises the caller-tag lookup in the store (all backends). + user, arn = self._caller(tags={"team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_deny_no_match_allows(self): + # The tag resolves to "contractors", so a Deny gated on "employees" is + # inert and the Allow grants. If the store tag lookup were broken the + # tag would resolve to nothing and this would still allow, so pair it + # with test_tag_deny_matches_blocks. + user, arn = self._caller(tags={"team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "employees"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_external_id_notequals_deny_blocks_when_absent(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"sts:ExternalId": "secret"}}), + ) + assert self._assume(trust, arn) == DENY + # Presenting the matching external id lifts the Deny. + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + # external_id is a separate assume parameter. + r2 = requests.post( + f"{self.mgmt.base_url}/accounts/{self.account_id}/roles/{role}/assume", + json={"caller_arn": arn, "session_name": "bb-session", + "duration_seconds": 3600, "external_id": "secret"}, + auth=self.mgmt.admin_auth, timeout=30, verify=self.mgmt.verify, + ) + assert r2.status_code == ALLOW, r2.text + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_account_root_principal_deny_blocks(self): + user, arn = self._caller() + root = f"arn:aws:iam::{self.account_id}:root" + try: + trust = _trust(_stmt("Allow", {"AWS": arn}), _stmt("Deny", {"AWS": root})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_bare_account_id_principal_deny_blocks(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}), _stmt("Deny", {"AWS": self.account_id})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_tagsession_only_allows_plain_assume(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, action="sts:TagSession"), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_session_tags_without_tagsession_denied(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn})) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_session_tags_with_tagsession_allowed(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"])) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_lowercase_deny_enforced(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}), _stmt("deny", {"AWS": arn})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_single_object_statement_allows(self): + user, arn = self._caller() + try: + trust = {"Version": "2012-10-17", "Statement": _stmt("Allow", {"AWS": arn})} + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Resolved caller-identity keys: aws:PrincipalArn / aws:PrincipalAccount + + def test_deny_principalarn_eq_self_blocks(self): + # A Deny on a broad principal gated on the caller's own ARN blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalArn": arn}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_principalarn_notequals_self_does_not_block(self): + # StringNotEquals on the caller's own ARN is false, so the Deny is inert. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalArn": arn}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_principalarn_grants(self): + # Restrict a broad principal to one ARN: the matching caller is granted. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalArn": arn}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_principalaccount_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalAccount": self.account_id}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_principalaccount_notequals_in_account_does_not_block(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalAccount": self.account_id}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Resolved session-tag keys: aws:RequestTag/* and aws:TagKeys --------- + + def test_deny_requesttag_match_blocks(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"]), + _stmt("Deny", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"StringEquals": {"aws:RequestTag/env": "prod"}}), + ) + assert self._assume(trust, arn, session_tags={"env": "prod"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_requesttag_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"StringEquals": {"aws:RequestTag/env": "dev"}})) + assert self._assume(trust, arn, session_tags={"env": "dev"}) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_tagkeys_foranyvalue_blocks(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"]), + _stmt("Deny", {"AWS": arn}, action=["sts:AssumeRole", "sts:TagSession"], + condition={"ForAnyValue:StringEquals": {"aws:TagKeys": "env"}}), + ) + assert self._assume(trust, arn, session_tags={"env": "prod"}) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Case 3: a key STS populates but ExtendDB cannot resolve (fail closed) + + def test_deny_on_absent_org_key_does_not_block(self): + # An ExtendDB account is a standalone AWS account, so aws:PrincipalOrgID + # is absent; StringEquals on an absent key does not match, so the Deny is + # inert and assumption is allowed. Matches live IAM for a standalone + # account. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalOrgID": "o-example1"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_allow_gate_on_absent_org_key_does_not_grant(self): + # An Allow gated solely on aws:PrincipalOrgID does not grant: the key is + # absent for a standalone account, so the positive operator does not + # match. Matches live IAM for a standalone account. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": "*"}, + condition={"StringEquals": {"aws:PrincipalOrgID": "o-example1"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_deny_negated_on_absent_org_key_blocks(self): + # StringNotEquals on an absent organization key matches (the negated + # operator on an absent single-valued key), so the Deny applies and + # blocks. Matches live IAM for a standalone account. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"StringNotEquals": {"aws:PrincipalOrgID": "o-example1"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Round three keys on the trust path (measured live) -- + + def test_sourceip_loopback_grants(self): + # The AssumeRole call arrives over the IPv4 loopback, so aws:SourceIp is + # 127.0.0.1 and IpAddress on the loopback grants. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"IpAddress": {"aws:SourceIp": "127.0.0.1/32"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_sourceip_other_range_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_notipaddress_deny_blocks_caller_outside_range(self): + # Deny unless the caller is inside 10.0.0.0/8. The loopback caller is + # outside it, so NotIpAddress matches and the Deny blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"NotIpAddress": {"aws:SourceIp": "10.0.0.0/8"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_useragent_stringlike_grants(self): + # The management client's User-Agent is python-requests. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:UserAgent": "*requests*"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_useragent_stringlike_no_match_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:UserAgent": "*ZZZNoSuchAgentZZZ*"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_userid_like_aida_grants(self): + # The caller is an IAM user, so aws:userid begins with AIDA. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringLike": {"aws:userid": "AIDA*"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_userid_present_null_true_does_not_grant(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"Null": {"aws:userid": "true"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Tag key is resolved case-insensitively (stored `Team`, policy `team`) -- + + def test_tag_key_case_insensitive_positive_deny_blocks(self): + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_positive_allow_grants(self): + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalTag/team": "contractors"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_negated_allow_does_not_overgrant(self): + # The tag resolves case-insensitively to "contractors", so StringNotEquals + # is false and the Allow does not grant. If the key were matched + # case-sensitively the tag would read as absent, the negated-absent rule + # would fire, and the excluded contractor would be granted. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringNotEquals": {"aws:PrincipalTag/team": "contractors"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tag_key_case_insensitive_negated_deny_does_not_overdeny(self): + # StringNotEquals is false on the resolved tag, so the Deny is inert and + # the Allow grants. Case-sensitive matching would over-deny here. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"aws:PrincipalTag/team": "contractors"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Condition key NAMES are resolved case-insensitively ---------- + + def test_keyname_case_insensitive_principaltype_lowercase_grants(self): + # aws:principaltype (lower) resolves like aws:PrincipalType; the caller + # is a user, so the gated Allow grants. Matches live STS. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltype": "User"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_principaltype_deny_lowercase_blocks(self): + # A Deny gated on a miscased key must still be enforced: aws:principaltype + # resolves to User, so the Deny applies. A case-sensitive lookup would + # drop the Deny and grant. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": "*"}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltype": "User"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_securetransport_lowercase_guard(self): + # aws:securetransport (lower) resolves to true over TLS, so the Deny on + # =false is inert and the Allow grants. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"Bool": {"aws:securetransport": "false"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_principaltag_prefix_lowercase(self): + # The tag prefix aws:principaltag/ (lower) resolves like aws:PrincipalTag/. + user, arn = self._caller(tags={"Team": "contractors"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:principaltag/team": "contractors"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_keyname_case_insensitive_externalid_lowercase(self): + # sts:externalid (lower) resolves like sts:ExternalId. Deny unless the + # external id equals secret; with the matching id the Deny is inert. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEquals": {"sts:externalid": "secret"}}), + ) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + with_id = self.mgmt.assume_role( + self.account_id, role, arn, "bb-session", external_id="secret") + assert with_id.status_code == ALLOW, with_id.text + no_id = self.mgmt.assume_role(self.account_id, role, arn, "bb-session") + assert no_id.status_code == DENY, no_id.text + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + + # -- Resolved request-wide keys on the trust path (T8) ------------------ + + def test_trust_folding_rolesessionname_key_does_not_grant(self): + # The trust path matches ASCII-case only (no Unicode folding). The + # key "sts:RoleSe\u00dfionName" (sharp s) case-folds to + # "sts:rolesessionname", the real key, so under Unicode folding an Allow + # gated on it would match the session name "bb-session" and grant. On + # the ASCII-only path the key reads absent, the Allow does not fire, and + # the assume is denied, matching live STS. This folds to a real key, so + # it fails on a folding build and passes here (unlike the old sharp-s + # SecureTransport key, which folds to "...transsport" and never matched). + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": { + "sts:RoleSe\u00dfionName": "bb-session"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_trust_folding_principaltag_key_does_not_grant(self): + # Companion row: "aws:PrincipalTag/cla\u00df" folds to + # "aws:PrincipalTag/class". The caller is tagged class=prod, so under + # Unicode folding an Allow gated on it would match and grant; on the + # ASCII-only path the key reads absent and the assume is denied. + user, arn = self._caller(tags={"class": "prod"}) + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": { + "aws:PrincipalTag/cla\u00df": "prod"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_trust_rejects_char_above_latin1(self): + # The service will not store a trust policy with a code point above + # U+00FF (CreateRole InternalFailure); ExtendDB rejects it at create. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:Reque\u0100edRegion": "us-east-1"}})) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) + assert resp.status_code == REJECT, resp.text + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_rolesessionname_deny_blocks_matching_session(self): + # _assume uses session name "bb-session". A Deny gated on + # sts:RoleSessionName=bb-session must block. Before the fix the key was + # absent and the Deny was dropped (assume granted). + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "bb-session"}}), + ) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_rolesessionname_deny_other_does_not_block(self): + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "other-name"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_rolesessionname_allow_gate_grants_matching_session(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"sts:RoleSessionName": "bb-session"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_tls_guard_does_not_block_over_tls(self): + # Allow, plus Deny if Bool aws:SecureTransport=false. The call is over + # TLS so SecureTransport is true, the deny does not fire, assumption is + # granted. Live STS: ALLOW. Round one failed closed on this guard. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"Bool": {"aws:SecureTransport": "false"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_secure_transport_true_grants(self): + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"Bool": {"aws:SecureTransport": "true"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_principaltype_user_grants(self): + # The caller is an IAM user, so aws:PrincipalType is User. Live STS: ALLOW. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalType": "User"}})) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_principaltype_assumedrole_does_not_grant(self): + # The caller is a user, so PrincipalType=AssumedRole does not match and + # the gated Allow does not grant. Live STS: DENY. + user, arn = self._caller() + try: + trust = _trust(_stmt("Allow", {"AWS": arn}, + condition={"StringEquals": {"aws:PrincipalType": "AssumedRole"}})) + assert self._assume(trust, arn) == DENY + finally: + self.mgmt.delete_user(self.account_id, user) + + def test_string_not_equals_ignore_case_external_id(self): + # StringNotEqualsIgnoreCase is implemented, so a matching external id + # (any case) makes the condition false and the Deny inert; an absent + # external id matches the negated operator and the Deny blocks. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": arn}, + condition={"StringNotEqualsIgnoreCase": {"sts:ExternalId": "secret"}}), + ) + role = f"bbr-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + assert resp.status_code == 201, resp.text + try: + r = requests.post( + f"{self.mgmt.base_url}/accounts/{self.account_id}/roles/{role}/assume", + json={"caller_arn": arn, "session_name": "bb-session", + "duration_seconds": 3600, "external_id": "SECRET"}, + auth=self.mgmt.admin_auth, timeout=30, verify=self.mgmt.verify, + ) + assert r.status_code == ALLOW, r.text + assert self.mgmt.assume_role( + self.account_id, role, arn, "bb-session").status_code == DENY + finally: + self.mgmt.delete_role(self.account_id, role) + finally: + self.mgmt.delete_user(self.account_id, user) + + @pytest.mark.parametrize("trust", [ + _trust(_stmt("Allow", "arn:aws:iam::111122223333:user/x")), # bare string ARN / non-object + _trust({"Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, "Action": "sts:AssumeRole"}), # missing Effect + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, key="NotPrincipal")), # NotPrincipal + _trust({"Effect": "Deny", "Action": "sts:AssumeRole"}), # missing Principal + _trust(_stmt("Allow", "*")), # bare "*" principal + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/bb-*"})), # wildcard in ARN + _trust({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}}), # missing Action + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}, action="sts:*")), # sts:* + _trust({"Effect": "Deny", "Principal": {"AWS": "arn:aws:iam::111122223333:user/x"}, + "Action": "sts:AssumeRole", "Resource": "*"}), # Resource present + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEqualz": {"aws:PrincipalTag/team": "contractors"}})), # unknown operator + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": "contractors"})), # condition not object + ]) + def test_create_rejects_malformed_trust_policy(self, trust): + role = f"bbrej-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) + assert resp.status_code == REJECT, f"expected reject, got {resp.status_code}: {resp.text}" + + @pytest.mark.parametrize("trust", [ + {"Statement": [_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"})]}, # missing Version + {"Version": "2012-10-17", "Statement": _stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"})}, # single object + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}), + _stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": {"aws:PrincipalTag/team": 5}})), # numeric condition value + _trust(_stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"StringEquals": {"aws:NoSuchKey": "x"}})), # unknown condition key + _trust(_stmt("Allow", {"AWS": "arn:aws:iam::111122223333:user/x"}), + _stmt("Deny", {"AWS": "arn:aws:iam::111122223333:user/x"}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}})), # malformed CIDR (trust accepts) + ]) + def test_create_accepts_iam_accepted_trust_policy(self, trust): + role = f"bbacc-{uuid.uuid4().hex[:8]}" + resp = self.mgmt.create_role(self.account_id, role, trust) + try: + assert resp.status_code == 201, f"expected accept, got {resp.status_code}: {resp.text}" + finally: + if resp.status_code == 201: + self.mgmt.delete_role(self.account_id, role) + + def test_malformed_cidr_trust_deny_drops_matching_service(self): + # Live STS accepts a malformed CIDR in a trust policy and drops the Deny + # (the condition never matches), so assumption is granted. ExtendDB + # matches: the trust write accepts it and ip_in_cidr returns no match. + user, arn = self._caller() + try: + trust = _trust( + _stmt("Allow", {"AWS": arn}), + _stmt("Deny", {"AWS": "*"}, + condition={"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}}), + ) + assert self._assume(trust, arn) == ALLOW + finally: + self.mgmt.delete_user(self.account_id, user) diff --git a/tests/test_console_integration.py b/tests/test_console_integration.py index f07fa6aef..402d9b2d4 100755 --- a/tests/test_console_integration.py +++ b/tests/test_console_integration.py @@ -431,6 +431,28 @@ def test_delete_role(self): resp = self.console.get(f"/accounts/{self.account_id}") assert role not in resp.text + + @pytest.mark.parametrize("statement", [ + # The service refuses each of these trust policies at CreateRole + # (MalformedPolicyDocument); the management API refuses them too. + {"Effect": "Allow", "Principal": "*", "Action": "sts:AssumeRole"}, + {"Effect": "Allow", "Principal": {"AWS": "ACCOUNT_ROOT"}, "Action": "*"}, + {"Effect": "Allow", "Principal": {"AWS": "ACCOUNT_ROOT"}, + "Action": "sts:AssumeRole", "Resource": "*"}, + ], ids=["bare-star-principal", "star-action", "resource-field"]) + def test_create_role_rejects_trust_policy_the_service_refuses(self, statement): + role = f"bad-{uuid.uuid4().hex[:8]}" + stmt = json.loads(json.dumps(statement).replace( + "ACCOUNT_ROOT", f"arn:aws:iam::{self.account_id}:root")) + resp = self.console.post(f"/accounts/{self.account_id}/roles/new", { + "role_name": role, + "trust_policy": json.dumps({"Version": "2012-10-17", "Statement": [stmt]}), + }) + # The form is re-rendered with an error instead of redirecting. + assert resp.status_code == 200 + assert "Invalid trust policy" in resp.text + resp = self.console.get(f"/accounts/{self.account_id}") + assert role not in resp.text # --------------------------------------------------------------------------- # Policy CRUD # --------------------------------------------------------------------------- @@ -459,6 +481,33 @@ def _full_access_doc(self) -> str: }], }) + def test_user_policy_rejects_document_the_service_refuses(self): + # A malformed CIDR and a non-alphanumeric Sid are each refused by the + # service at PutUserPolicy and by the management API. + user = f"pv-{uuid.uuid4().hex[:8]}" + self.console.post(f"/accounts/{self.account_id}/users/new", { + "user_name": user, + "password": "TestPass123!", + }) + bad_docs = [ + {"Version": "2012-10-17", "Statement": [{ + "Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*", + "Condition": {"IpAddress": {"aws:SourceIp": "10.0.0.0/33"}}}]}, + {"Version": "2012-10-17", "Statement": [{ + "Sid": "Allow-Read", "Effect": "Allow", + "Action": "dynamodb:GetItem", "Resource": "*"}]}, + ] + for i, doc in enumerate(bad_docs): + policy = f"bad{i}-{uuid.uuid4().hex[:6]}" + resp = self.console.post( + f"/accounts/{self.account_id}/users/{user}/policies/new", + {"policy_name": policy, "policy_document": json.dumps(doc)}, + ) + assert resp.status_code == 200 + assert "Invalid policy document" in resp.text + resp = self.console.get(f"/accounts/{self.account_id}/users/{user}") + assert policy not in resp.text + def test_user_policy_crud(self): user = f"pu-{uuid.uuid4().hex[:8]}" policy = f"pol-{uuid.uuid4().hex[:8]}"