Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/fakecloud-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,22 @@ pub trait ScpResolver: Send + Sync {
fn scps_for(&self, principal: &Principal) -> Option<Vec<String>>;
}

/// 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."
///
Expand Down
31 changes: 26 additions & 5 deletions crates/fakecloud-iam/src/sts_service/assume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
105 changes: 103 additions & 2 deletions crates/fakecloud-iam/src/sts_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ pub struct StsService {
state: SharedIamState,
snapshot_store: Option<Arc<dyn SnapshotStore>>,
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<Arc<dyn fakecloud_core::auth::OrgMembershipResolver>>,
}

mod assume;
Expand All @@ -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<dyn fakecloud_core::auth::OrgMembershipResolver>,
) -> Self {
self.org_membership = Some(resolver);
self
}

pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
self.snapshot_store = Some(store);
self
Expand Down Expand Up @@ -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<dyn fakecloud_core::auth::OrgMembershipResolver> {
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());
Expand Down Expand Up @@ -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![
Expand All @@ -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![
Expand Down Expand Up @@ -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
Expand Down
104 changes: 103 additions & 1 deletion crates/fakecloud-organizations/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<dyn OrgMembershipResolver> {
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::*;
Expand Down Expand Up @@ -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"));
}
}
8 changes: 7 additions & 1 deletion crates/fakecloud-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading