From 618c0c815461885c433eb12c164122cf0cc537ce Mon Sep 17 00:00:00 2001 From: abanna Date: Thu, 25 Jun 2026 21:42:05 -0500 Subject: [PATCH 1/2] fix(iam): resolve attached AWS-managed policies for any account AWS-managed policies (arn:aws:iam::aws:policy/...) are global and live in the in-memory catalog, not the account-partitioned policy store; getPolicy already serves them from the catalog. Several IAM read paths, however, resolved attached policy ARNs straight from the account-scoped store (policies.get(arn) / the flatMap variants), so a managed policy attached to a principal owned by a non-default account was silently dropped. Add a catalog-aware resolvePolicy(arn) helper modeled on getPolicy and route the affected reads through it: listAttachedUser/Group/RolePolicies, the user/role permissions-boundary document resolvers, and collectUser/RolePolicies (the SimulatePrincipalPolicy / caller-context paths). Customer-policy-only logic and attachment-count bookkeeping on detach are left untouched. --- .../floci/services/iam/IamService.java | 30 ++++-- .../iam/IamManagedPolicyAccountScopeTest.java | 99 +++++++++++++++++++ 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/main/java/io/github/hectorvent/floci/services/iam/IamService.java b/src/main/java/io/github/hectorvent/floci/services/iam/IamService.java index 4ee2335df1..d50ffe9a74 100644 --- a/src/main/java/io/github/hectorvent/floci/services/iam/IamService.java +++ b/src/main/java/io/github/hectorvent/floci/services/iam/IamService.java @@ -439,6 +439,20 @@ public IamPolicy getPolicy(String policyArn) { "Policy " + policyArn + " does not exist.", 404)); } + /** + * Resolves a policy by ARN without throwing, mirroring {@link #getPolicy} so that + * AWS-managed policies (arn:aws:iam::aws:policy/...) are served from the global catalog + * rather than the account-partitioned store. Attached-policy read paths must use this: + * a managed policy attached to a principal owned by a non-default account is absent from + * that account's {@link #policies} partition and would otherwise be silently dropped. + */ + private Optional resolvePolicy(String arn) { + if (arn != null && arn.startsWith(AwsManagedPolicies.ARN_PREFIX)) { + return Optional.ofNullable(awsManagedPolicies.get(arn)); + } + return policies.get(arn); + } + private void rejectIfAwsManaged(String policyArn) { if (policyArn != null && policyArn.startsWith(AwsManagedPolicies.ARN_PREFIX)) { throw new AwsException("AccessDenied", @@ -615,7 +629,7 @@ public void detachUserPolicy(String userName, String policyArn) { public List listAttachedUserPolicies(String userName, String pathPrefix) { return getUser(userName).getAttachedPolicyArns().stream() - .flatMap(arn -> policies.get(arn).stream()) + .flatMap(arn -> resolvePolicy(arn).stream()) .filter(p -> pathPrefix == null || p.getPath().startsWith(pathPrefix)) .toList(); } @@ -650,7 +664,7 @@ public void detachGroupPolicy(String groupName, String policyArn) { public List listAttachedGroupPolicies(String groupName, String pathPrefix) { return getGroup(groupName).getAttachedPolicyArns().stream() - .flatMap(arn -> policies.get(arn).stream()) + .flatMap(arn -> resolvePolicy(arn).stream()) .filter(p -> pathPrefix == null || p.getPath().startsWith(pathPrefix)) .toList(); } @@ -685,7 +699,7 @@ public void detachRolePolicy(String roleName, String policyArn) { public List listAttachedRolePolicies(String roleName, String pathPrefix) { return getRole(roleName).getAttachedPolicyArns().stream() - .flatMap(arn -> policies.get(arn).stream()) + .flatMap(arn -> resolvePolicy(arn).stream()) .filter(p -> pathPrefix == null || p.getPath().startsWith(pathPrefix)) .toList(); } @@ -1112,7 +1126,7 @@ public CallerContext resolvePrincipalContext(String principalArn) { private String resolveUserBoundaryDocument(String userName) { return users.get(userName) .map(IamUser::getPermissionsBoundaryArn) - .flatMap(arn -> policies.get(arn)) + .flatMap(this::resolvePolicy) .map(IamPolicy::getDefaultDocument) .orElse(null); } @@ -1124,7 +1138,7 @@ private String resolveRoleBoundaryDocument(String roleArn) { String roleName = roleArn.contains("/") ? roleArn.substring(roleArn.lastIndexOf('/') + 1) : roleArn; return roles.get(roleName) .map(IamRole::getPermissionsBoundaryArn) - .flatMap(arn -> policies.get(arn)) + .flatMap(this::resolvePolicy) .map(IamPolicy::getDefaultDocument) .orElse(null); } @@ -1183,7 +1197,7 @@ private List collectUserPolicies(String userName) { // User attached managed policies for (String arn : user.getAttachedPolicyArns()) { - Optional p = policies.get(arn); + Optional p = resolvePolicy(arn); if (p.isPresent() && p.get().getDefaultDocument() != null) { docs.add(p.get().getDefaultDocument()); } @@ -1196,7 +1210,7 @@ private List collectUserPolicies(String userName) { IamGroup group = groupOpt.get(); docs.addAll(group.getInlinePolicies().values()); for (String arn : group.getAttachedPolicyArns()) { - Optional p = policies.get(arn); + Optional p = resolvePolicy(arn); if (p.isPresent() && p.get().getDefaultDocument() != null) { docs.add(p.get().getDefaultDocument()); } @@ -1223,7 +1237,7 @@ private List collectRolePolicies(String roleArn) { // Role attached managed policies for (String arn : role.getAttachedPolicyArns()) { - Optional p = policies.get(arn); + Optional p = resolvePolicy(arn); if (p.isPresent() && p.get().getDefaultDocument() != null) { docs.add(p.get().getDefaultDocument()); } diff --git a/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java b/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java index e6bde34e55..1577b101d8 100644 --- a/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java @@ -5,13 +5,17 @@ import io.github.hectorvent.floci.core.common.RequestContext; import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.services.iam.model.CallerContext; import io.github.hectorvent.floci.services.iam.model.IamPolicy; +import io.github.hectorvent.floci.services.iam.model.IamRole; +import io.github.hectorvent.floci.services.iam.model.IamUser; import jakarta.enterprise.inject.Instance; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -153,4 +157,99 @@ void listPoliciesScopesLocalToCallerAndDoesNotDuplicateMirroredManaged() { assertEquals(AwsManagedPolicies.POLICIES.size(), defAll.size()); assertEquals(1L, defAll.stream().filter(p -> mirroredManagedArn.equals(p.getArn())).count()); } + + @Test + void attachedManagedPolicyResolvesForUserInNonDefaultAccount() { + // Request runs as account 111...; managed policies are only mirrored into the default + // account at seed time. A managed policy attached to a user owned by 111... was silently + // dropped by the attached-policy read paths, which resolved straight from the + // account-partitioned store instead of the global catalog. + Instance ctx = requestContextFor(REQUEST_ACCT); + InMemoryStorage rawUsers = new InMemoryStorage<>(); + InMemoryStorage rawPolicies = new InMemoryStorage<>(); + AccountAwareStorageBackend users = new AccountAwareStorageBackend<>(rawUsers, ctx, DEFAULT_ACCT); + AccountAwareStorageBackend policies = + new AccountAwareStorageBackend<>(rawPolicies, ctx, DEFAULT_ACCT); + + String managedArn = AwsManagedPolicies.ARN_PREFIX + "/service-role/AWSLambdaBasicExecutionRole"; + // A customer policy attached to the same user — must stay account-scoped (control). + String customerArn = "arn:aws:iam::" + REQUEST_ACCT + ":policy/app-policy"; + policies.putForAccount(REQUEST_ACCT, customerArn, + new IamPolicy("ANPAAPP000000001", "app-policy", "/", customerArn, + "app", AwsManagedPolicies.PERMISSIVE_DOCUMENT)); + + IamUser user = new IamUser("AIDAUSER00000001", "app-user", "/", + "arn:aws:iam::" + REQUEST_ACCT + ":user/app-user"); + user.getAttachedPolicyArns().add(managedArn); + user.getAttachedPolicyArns().add(customerArn); + users.putForAccount(REQUEST_ACCT, "app-user", user); + + IamService service = new IamService( + users, new InMemoryStorage<>(), new InMemoryStorage<>(), + policies, + new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + new RegionResolver("us-east-1", DEFAULT_ACCT)); + + // ListAttachedUserPolicies returns both the managed (catalog) and customer (scoped) policies. + List attached = service.listAttachedUserPolicies("app-user", null); + assertEquals(2, attached.size()); + assertTrue(attached.stream().anyMatch(p -> managedArn.equals(p.getArn()))); + assertTrue(attached.stream().anyMatch(p -> customerArn.equals(p.getArn()))); + + // SimulatePrincipalPolicy (resolvePrincipalContext -> collectUserPolicies) now picks up + // the attached managed policy's document for a non-default-account principal. + CallerContext caller = service.resolvePrincipalContext( + "arn:aws:iam::" + REQUEST_ACCT + ":user/app-user"); + assertTrue(caller.identityPolicies().contains(AwsManagedPolicies.PERMISSIVE_DOCUMENT)); + + // Control: the customer policy is genuinely account-scoped — invisible from another account. + Instance otherCtx = requestContextFor("222222222222"); + IamService otherService = new IamService( + new AccountAwareStorageBackend<>(rawUsers, otherCtx, DEFAULT_ACCT), + new InMemoryStorage<>(), new InMemoryStorage<>(), + new AccountAwareStorageBackend<>(rawPolicies, otherCtx, DEFAULT_ACCT), + new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + new RegionResolver("us-east-1", DEFAULT_ACCT)); + // The user does not exist under account 222..., so the customer policy never leaks; assert + // the catalog policy still resolves directly regardless of account. + assertFalse(otherService.listPolicies("Local", null).stream() + .anyMatch(p -> customerArn.equals(p.getArn()))); + assertNotNull(otherService.getPolicy(managedArn)); + } + + @Test + void attachedManagedPolicyResolvesForRoleInNonDefaultAccountIncludingBoundary() { + Instance ctx = requestContextFor(REQUEST_ACCT); + InMemoryStorage rawRoles = new InMemoryStorage<>(); + AccountAwareStorageBackend roles = new AccountAwareStorageBackend<>(rawRoles, ctx, DEFAULT_ACCT); + AccountAwareStorageBackend policies = + new AccountAwareStorageBackend<>(new InMemoryStorage<>(), ctx, DEFAULT_ACCT); + + String managedArn = AwsManagedPolicies.ARN_PREFIX + "/service-role/AWSLambdaBasicExecutionRole"; + String boundaryArn = AwsManagedPolicies.ARN_PREFIX + "/PowerUserAccess"; + + IamRole role = new IamRole("AROLE00000000001", "task-role", "/", + "arn:aws:iam::" + REQUEST_ACCT + ":role/task-role", "{}"); + role.getAttachedPolicyArns().add(managedArn); + role.setPermissionsBoundaryArn(boundaryArn); + roles.putForAccount(REQUEST_ACCT, "task-role", role); + + IamService service = new IamService( + new InMemoryStorage<>(), new InMemoryStorage<>(), roles, + policies, + new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + new RegionResolver("us-east-1", DEFAULT_ACCT)); + + // ListAttachedRolePolicies resolves the managed policy from the catalog for account 111... + List attached = service.listAttachedRolePolicies("task-role", null); + assertEquals(1, attached.size()); + assertEquals(managedArn, attached.get(0).getArn()); + + // resolvePrincipalContext (collectRolePolicies + resolveRoleBoundaryDocument) resolves both + // the attached managed policy and the managed permissions boundary for a non-default account. + CallerContext caller = service.resolvePrincipalContext( + "arn:aws:iam::" + REQUEST_ACCT + ":role/task-role"); + assertTrue(caller.identityPolicies().contains(AwsManagedPolicies.PERMISSIVE_DOCUMENT)); + assertEquals(AwsManagedPolicies.PERMISSIVE_DOCUMENT, caller.boundaryPolicyDocument()); + } } From 14ed14420f89579207ebdad52915db8878d6a3c2 Mon Sep 17 00:00:00 2001 From: abanna Date: Wed, 1 Jul 2026 22:29:57 -0500 Subject: [PATCH 2/2] test(iam): cover attached managed-policy resolution for a group in a non-default account Adds the group analog of the existing user/role tests, exercising listAttachedGroupPolicies (the resolvePolicy catalog fix) for a group owned by a non-default account, with a customer policy attached to the same group as an account-scoping control. --- .../iam/IamManagedPolicyAccountScopeTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java b/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java index 1577b101d8..e5c4973dca 100644 --- a/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/iam/IamManagedPolicyAccountScopeTest.java @@ -6,6 +6,7 @@ import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.services.iam.model.CallerContext; +import io.github.hectorvent.floci.services.iam.model.IamGroup; import io.github.hectorvent.floci.services.iam.model.IamPolicy; import io.github.hectorvent.floci.services.iam.model.IamRole; import io.github.hectorvent.floci.services.iam.model.IamUser; @@ -217,6 +218,44 @@ void attachedManagedPolicyResolvesForUserInNonDefaultAccount() { assertNotNull(otherService.getPolicy(managedArn)); } + @Test + void attachedManagedPolicyResolvesForGroupInNonDefaultAccount() { + // Symmetric with the user/role cases: a managed policy attached to a group owned by a + // non-default account must resolve from the global catalog (the resolvePolicy fix applied + // to listAttachedGroupPolicies), while a customer policy attached to the same group stays + // account-scoped. + Instance ctx = requestContextFor(REQUEST_ACCT); + InMemoryStorage rawGroups = new InMemoryStorage<>(); + InMemoryStorage rawPolicies = new InMemoryStorage<>(); + AccountAwareStorageBackend groups = new AccountAwareStorageBackend<>(rawGroups, ctx, DEFAULT_ACCT); + AccountAwareStorageBackend policies = + new AccountAwareStorageBackend<>(rawPolicies, ctx, DEFAULT_ACCT); + + String managedArn = AwsManagedPolicies.ARN_PREFIX + "/service-role/AWSLambdaBasicExecutionRole"; + String customerArn = "arn:aws:iam::" + REQUEST_ACCT + ":policy/group-policy"; + policies.putForAccount(REQUEST_ACCT, customerArn, + new IamPolicy("ANPAGRP000000001", "group-policy", "/", customerArn, + "grp", AwsManagedPolicies.PERMISSIVE_DOCUMENT)); + + IamGroup group = new IamGroup("AGPAGROUP0000001", "app-group", "/", + "arn:aws:iam::" + REQUEST_ACCT + ":group/app-group"); + group.getAttachedPolicyArns().add(managedArn); + group.getAttachedPolicyArns().add(customerArn); + groups.putForAccount(REQUEST_ACCT, "app-group", group); + + IamService service = new IamService( + new InMemoryStorage<>(), groups, new InMemoryStorage<>(), + policies, + new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + new RegionResolver("us-east-1", DEFAULT_ACCT)); + + // ListAttachedGroupPolicies returns both the managed (catalog) and customer (scoped) policies. + List attached = service.listAttachedGroupPolicies("app-group", null); + assertEquals(2, attached.size()); + assertTrue(attached.stream().anyMatch(p -> managedArn.equals(p.getArn()))); + assertTrue(attached.stream().anyMatch(p -> customerArn.equals(p.getArn()))); + } + @Test void attachedManagedPolicyResolvesForRoleInNonDefaultAccountIncludingBoundary() { Instance ctx = requestContextFor(REQUEST_ACCT);