diff --git a/crates/fakecloud-core/src/auth.rs b/crates/fakecloud-core/src/auth.rs index 0c4fc0d3d..c0c84f2e0 100644 --- a/crates/fakecloud-core/src/auth.rs +++ b/crates/fakecloud-core/src/auth.rs @@ -464,6 +464,22 @@ pub trait ScpResolver: Send + Sync { fn scps_for(&self, principal: &Principal) -> Option>; } +/// Abstraction over "does the organization topology permit `caller_account` to +/// mint centralized-root (`sts:AssumeRoot`) credentials for `target_account`". +/// Implemented by `fakecloud-organizations`, which owns the membership graph +/// the IAM/STS crate has no visibility into. +/// +/// Returns `true` only when an organization exists, `target_account` is a +/// member of it, and `caller_account` is that org's management account (or a +/// registered delegated administrator for centralized root access). Any other +/// case — no org, target not enrolled, caller not privileged — returns +/// `false`, so a bare `sts:AssumeRoot` grant can no longer escalate to root +/// over an arbitrary account. Same-account AssumeRoot is handled by the caller +/// and never consults this resolver. +pub trait OrgMembershipResolver: Send + Sync { + fn can_assume_root_into(&self, caller_account: &str, target_account: &str) -> bool; +} + /// Abstraction over "given a service + a fully-qualified resource ARN, /// return the resource-based policy attached to that resource, if any." /// diff --git a/crates/fakecloud-iam/src/sts_service/assume.rs b/crates/fakecloud-iam/src/sts_service/assume.rs index bd20ec6dd..958cf6ae5 100644 --- a/crates/fakecloud-iam/src/sts_service/assume.rs +++ b/crates/fakecloud-iam/src/sts_service/assume.rs @@ -925,11 +925,13 @@ impl StsService { // managing itself) is always allowed and matches the recorded AWS // baseline; only cross-account targets require the feature enabled. if target_account != req.account_id { - let accounts = self.state.read(); - let enabled = accounts - .get(&req.account_id) - .map(|s| s.organizations_root_sessions) - .unwrap_or(false); + let enabled = { + let accounts = self.state.read(); + accounts + .get(&req.account_id) + .map(|s| s.organizations_root_sessions) + .unwrap_or(false) + }; if !enabled { return Err(AwsServiceError::aws_error( StatusCode::FORBIDDEN, @@ -939,6 +941,25 @@ impl StsService { (EnableOrganizationsRootSessions).", )); } + // The RootSessions flag alone is not sufficient: the target must be + // a member of the caller's organization and the caller must be the + // management account (or a delegated administrator for centralized + // root access). Without this an account that merely enabled + // RootSessions could mint :root credentials for ANY account, in or + // out of its org (bug-hunt 5.2). + let org_permits = self + .org_membership + .as_ref() + .is_some_and(|r| r.can_assume_root_into(&req.account_id, &target_account)); + if !org_permits { + return Err(AwsServiceError::aws_error( + StatusCode::FORBIDDEN, + "AccessDeniedException", + "AssumeRoot into another account is permitted only for the organization's \ + management account (or a delegated administrator for centralized root \ + access) targeting a member account of the same organization.", + )); + } } // Don't call `compute_expiration_at` — that helper re-parses diff --git a/crates/fakecloud-iam/src/sts_service/mod.rs b/crates/fakecloud-iam/src/sts_service/mod.rs index ded59bf3f..4dae1d3ec 100644 --- a/crates/fakecloud-iam/src/sts_service/mod.rs +++ b/crates/fakecloud-iam/src/sts_service/mod.rs @@ -90,6 +90,10 @@ pub struct StsService { state: SharedIamState, snapshot_store: Option>, snapshot_lock: IamSnapshotLock, + /// Organization-membership oracle used to gate cross-account `AssumeRoot`. + /// `None` in single-account setups / unit tests, in which case + /// cross-account AssumeRoot is denied (no org topology to authorize it). + org_membership: Option>, } mod assume; @@ -103,9 +107,20 @@ impl StsService { state, snapshot_store: None, snapshot_lock: crate::persistence::new_snapshot_lock(), + org_membership: None, } } + /// Wire the organization-membership resolver used to authorize cross-account + /// `AssumeRoot`. Without it, cross-account AssumeRoot is always denied. + pub fn with_org_membership( + mut self, + resolver: Arc, + ) -> Self { + self.org_membership = Some(resolver); + self + } + pub fn with_snapshot_store(mut self, store: Arc) -> Self { self.snapshot_store = Some(store); self @@ -870,6 +885,29 @@ mod tests { (sts, state) } + /// Test double for the org-membership oracle. `can_assume_root_into` is true + /// only for the exact (caller, target) pairs it was seeded with. + struct MockOrgMembership { + allowed: Vec<(String, String)>, + } + + impl fakecloud_core::auth::OrgMembershipResolver for MockOrgMembership { + fn can_assume_root_into(&self, caller: &str, target: &str) -> bool { + self.allowed.iter().any(|(c, t)| c == caller && t == target) + } + } + + fn org_membership_allowing( + pairs: &[(&str, &str)], + ) -> std::sync::Arc { + std::sync::Arc::new(MockOrgMembership { + allowed: pairs + .iter() + .map(|(c, t)| (c.to_string(), t.to_string())) + .collect(), + }) + } + fn sts_request(action: &str, params: Vec<(&str, &str)>) -> AwsRequest { let mut qp = std::collections::HashMap::new(); qp.insert("Action".to_string(), action.to_string()); @@ -1922,11 +1960,14 @@ mod tests { #[tokio::test] async fn assume_root_with_account_id_succeeds() { let (svc, state) = make_sts_service(); - // AssumeRoot requires the RootSessions feature enabled (5.1). + // AssumeRoot requires the RootSessions feature enabled (5.1) AND, for a + // cross-account target, that the org topology authorizes it (5.2). state .write() .get_or_create("123456789012") .organizations_root_sessions = true; + let svc = + svc.with_org_membership(org_membership_allowing(&[("123456789012", "111122223333")])); let req = sts_request( "AssumeRoot", vec![ @@ -1946,11 +1987,14 @@ mod tests { #[tokio::test] async fn assume_root_with_arn_succeeds() { let (svc, state) = make_sts_service(); - // AssumeRoot requires the RootSessions feature enabled (5.1). + // AssumeRoot requires the RootSessions feature enabled (5.1) AND, for a + // cross-account target, org authorization (5.2). state .write() .get_or_create("123456789012") .organizations_root_sessions = true; + let svc = + svc.with_org_membership(org_membership_allowing(&[("123456789012", "444455556666")])); let req = sts_request( "AssumeRoot", vec![ @@ -1994,6 +2038,63 @@ mod tests { assert_eq!(err.code(), "AccessDeniedException"); } + #[tokio::test] + async fn assume_root_cross_account_denied_when_org_disallows() { + // §5.2: even with RootSessions enabled, a cross-account target that the + // organization does not authorize (target not a member, or caller not + // the management account) must be denied. Here the mock authorizes only + // 555566667777, not the 444455556666 being requested. + let (svc, state) = make_sts_service(); + state + .write() + .get_or_create("123456789012") + .organizations_root_sessions = true; + let svc = + svc.with_org_membership(org_membership_allowing(&[("123456789012", "555566667777")])); + let req = sts_request( + "AssumeRoot", + vec![ + ("TargetPrincipal", "arn:aws:iam::444455556666:root"), + ( + "TaskPolicyArn.arn", + "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials", + ), + ], + ); + let err = svc + .assume_root(&req) + .err() + .expect("cross-account AssumeRoot must be denied when the org disallows it"); + assert_eq!(err.code(), "AccessDeniedException"); + } + + #[tokio::test] + async fn assume_root_cross_account_denied_without_org_resolver() { + // §5.2: with no org resolver wired (single-account setup), there is no + // topology to authorize cross-account AssumeRoot, so it is denied even + // with the RootSessions flag set. + let (svc, state) = make_sts_service(); + state + .write() + .get_or_create("123456789012") + .organizations_root_sessions = true; + let req = sts_request( + "AssumeRoot", + vec![ + ("TargetPrincipal", "arn:aws:iam::444455556666:root"), + ( + "TaskPolicyArn.arn", + "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials", + ), + ], + ); + let err = svc + .assume_root(&req) + .err() + .expect("cross-account AssumeRoot must be denied without an org resolver"); + assert_eq!(err.code(), "AccessDeniedException"); + } + #[tokio::test] async fn assume_root_same_account_succeeds_without_root_sessions() { // The member root managing its OWN account does not require the diff --git a/crates/fakecloud-organizations/src/resolver.rs b/crates/fakecloud-organizations/src/resolver.rs index fbfcfea47..939c7b148 100644 --- a/crates/fakecloud-organizations/src/resolver.rs +++ b/crates/fakecloud-organizations/src/resolver.rs @@ -18,7 +18,7 @@ use std::sync::Arc; -use fakecloud_core::auth::{Principal, PrincipalType, ScpResolver}; +use fakecloud_core::auth::{OrgMembershipResolver, Principal, PrincipalType, ScpResolver}; use crate::state::SharedOrganizationsState; @@ -181,6 +181,50 @@ fn slr_role_name(arn: &str) -> Option<&str> { after_prefix.split('/').next() } +/// Service principal AWS registers for centralized-root-access delegated +/// administration. A member account delegated for this principal may perform +/// `sts:AssumeRoot` on other org members alongside the management account. +const ROOT_ACCESS_SERVICE_PRINCIPAL: &str = "iam.amazonaws.com"; + +/// [`OrgMembershipResolver`] over the shared organizations state — answers +/// whether the org topology permits a centralized-root (`AssumeRoot`) session. +pub struct OrganizationsMembershipResolver { + state: SharedOrganizationsState, +} + +impl OrganizationsMembershipResolver { + pub fn new(state: SharedOrganizationsState) -> Self { + Self { state } + } + + pub fn shared(state: SharedOrganizationsState) -> Arc { + Arc::new(Self::new(state)) + } +} + +impl OrgMembershipResolver for OrganizationsMembershipResolver { + fn can_assume_root_into(&self, caller_account: &str, target_account: &str) -> bool { + let guard = self.state.read(); + let Some(org) = guard.as_ref() else { + // No organization exists — there is no centralized root access to + // grant, so cross-account AssumeRoot is never permitted. + return false; + }; + // The target must be enrolled in this organization. + if !org.accounts.contains_key(target_account) { + return false; + } + // The caller must be the management account or a registered delegated + // administrator for centralized root access. + if org.is_management(caller_account) { + return true; + } + org.delegated_administrators + .get(ROOT_ACCESS_SERVICE_PRINCIPAL) + .is_some_and(|admins| admins.contains_key(caller_account)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -362,4 +406,62 @@ mod tests { // detached and nothing else is attached). assert!(docs.is_empty()); } + + // ── OrganizationsMembershipResolver (AssumeRoot gating, §5.2) ── + + #[test] + fn membership_resolver_denies_when_no_org() { + let state: SharedOrganizationsState = Arc::new(RwLock::new(None)); + let resolver = OrganizationsMembershipResolver::new(state); + assert!(!resolver.can_assume_root_into("111111111111", "222222222222")); + } + + #[test] + fn membership_resolver_allows_management_into_member() { + let mut org = OrganizationState::bootstrap("111111111111"); + org.enroll_account_if_missing("222222222222"); + let resolver = OrganizationsMembershipResolver::new(shared(org)); + // Management account -> enrolled member: allowed. + assert!(resolver.can_assume_root_into("111111111111", "222222222222")); + } + + #[test] + fn membership_resolver_denies_non_member_target() { + let org = OrganizationState::bootstrap("111111111111"); + let resolver = OrganizationsMembershipResolver::new(shared(org)); + // Target 999... is not enrolled -> denied even for the management acct. + assert!(!resolver.can_assume_root_into("111111111111", "999999999999")); + } + + #[test] + fn membership_resolver_denies_non_management_caller() { + let mut org = OrganizationState::bootstrap("111111111111"); + org.enroll_account_if_missing("222222222222"); + org.enroll_account_if_missing("333333333333"); + let resolver = OrganizationsMembershipResolver::new(shared(org)); + // A plain member (222...) is neither management nor a delegated admin, + // so it cannot AssumeRoot into a sibling member (333...). + assert!(!resolver.can_assume_root_into("222222222222", "333333333333")); + } + + #[test] + fn membership_resolver_allows_delegated_admin() { + let mut org = OrganizationState::bootstrap("111111111111"); + org.enroll_account_if_missing("222222222222"); + org.enroll_account_if_missing("333333333333"); + org.delegated_administrators + .entry(ROOT_ACCESS_SERVICE_PRINCIPAL.to_string()) + .or_default() + .insert( + "222222222222".to_string(), + crate::state::DelegatedAdministrator { + account_id: "222222222222".to_string(), + service_principal: ROOT_ACCESS_SERVICE_PRINCIPAL.to_string(), + registered_at: chrono::Utc::now(), + }, + ); + let resolver = OrganizationsMembershipResolver::new(shared(org)); + // 222... is a delegated admin for root access -> may target member 333. + assert!(resolver.can_assume_root_into("222222222222", "333333333333")); + } } diff --git a/crates/fakecloud-server/src/main.rs b/crates/fakecloud-server/src/main.rs index 42802d1f3..5dd0dbd09 100644 --- a/crates/fakecloud-server/src/main.rs +++ b/crates/fakecloud-server/src/main.rs @@ -1758,7 +1758,13 @@ async fn main() { // Share the snapshot lock between IamService and StsService so // writes from both services mutually serialize through one lock. let iam_snapshot_lock = iam_service.snapshot_lock(); - let mut sts_service = StsService::new(iam_state.clone()).with_snapshot_lock(iam_snapshot_lock); + let mut sts_service = StsService::new(iam_state.clone()) + .with_snapshot_lock(iam_snapshot_lock) + .with_org_membership( + fakecloud_organizations::resolver::OrganizationsMembershipResolver::shared( + organizations_state.clone(), + ), + ); if let Some(store) = iam_snapshot_store { sts_service = sts_service.with_snapshot_store(store); }