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 1d41af31ba..67c5840842 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 @@ -452,6 +452,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", @@ -628,7 +642,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(); } @@ -663,7 +677,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(); } @@ -698,7 +712,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(); } @@ -1125,7 +1139,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); } @@ -1137,7 +1151,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); } @@ -1196,7 +1210,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()); } @@ -1209,7 +1223,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()); } @@ -1236,7 +1250,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..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 @@ -5,13 +5,18 @@ 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.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; 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 +158,137 @@ 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 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); + 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()); + } }