From 937eaed26f85330a7f712a36e259a0b5cf8959ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Tue, 8 Sep 2026 23:41:33 -0400 Subject: [PATCH 01/99] feat(ssoadmin): add region operation --- .../test/SsoAdminAccountAssignmentTest.java | 19 +++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 35 +++++++++++++++- .../ssoadmin/model/RegionMetadata.java | 11 +++++ .../ssoadmin/SsoAdminIntegrationTest.java | 26 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 42 ++++++++++++++++++- 7 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/RegionMetadata.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 1c8fd42c6f..b9f927d605 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -14,6 +14,25 @@ @DisplayName("IAM Identity Center account assignments") class SsoAdminAccountAssignmentTest { + @Test + @DisplayName("adds an IAM Identity Center Region through the AWS SDK") + void addRegionUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + var added = sso.addRegion(request -> request + .instanceArn(instanceArn) + .regionName("ap-southeast-3")); + + assertThat(added.statusAsString()).isEqualTo("ADDING"); + assertThatThrownBy(() -> sso.addRegion(request -> request + .instanceArn(instanceArn) + .regionName("ap-southeast-3"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index b9f7612bb9..1f6e3f7da2 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -11,6 +11,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | Action | Description | | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | +| `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index fd5ffee22c..47964163b4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -26,6 +26,7 @@ public SsoAdminJsonHandler(SsoAdminService service, ObjectMapper mapper) { public Response handle(String action, JsonNode request, String callerAccountId) { return switch (action) { case "ListInstances" -> listInstances(callerAccountId); + case "AddRegion" -> addRegion(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -53,6 +54,11 @@ private Response listInstances(String callerAccountId) { return Response.ok(response).build(); } + private Response addRegion(JsonNode request) { + var region = service.addRegion(request); + return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); + } + private Response listPermissionSets(JsonNode request) { var page = service.listPermissionSets(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index d482ee0766..a4fb13ce4d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -11,6 +11,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -27,11 +28,14 @@ public class SsoAdminService implements Resettable { private static final String INSTANCE_ARN = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; private static final String IDENTITY_STORE_ID = "d-9067f2a3c1"; + private static final String PRIMARY_REGION = "us-east-1"; private static final Pattern PERMISSION_SET_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final Pattern PERMISSION_SET_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso:::permissionSet/(?:sso)?ins-[a-zA-Z0-9-.]{16}/ps-[a-zA-Z0-9-./]{16}"); private static final Pattern PRINCIPAL_ID = Pattern.compile("([0-9a-f]{10}-|)[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"); private static final Pattern MANAGED_POLICY_ARN = Pattern.compile("arn:aws:iam::aws:policy/.+"); + private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); private static final int PERMISSION_SET_QUOTA = 3500; + private static final int REGION_QUOTA = 6; private static final int MANAGED_POLICY_QUOTA = 25; private static final int MAX_INLINE_POLICY_BYTES = 32_768; private static final int MAX_INLINE_NON_WHITESPACE = 10_240; @@ -40,26 +44,45 @@ public class SsoAdminService implements Resettable { private final StorageBackend permissionSets; private final StorageBackend assignments; private final StorageBackend assignmentOperations; + private final StorageBackend regions; @Inject public SsoAdminService(StorageFactory storageFactory) { this( storageFactory.create("ssoadmin", "ssoadmin-permission-sets.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), - storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {})); + storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {})); } SsoAdminService(StorageBackend permissionSets, StorageBackend assignments, - StorageBackend assignmentOperations) { + StorageBackend assignmentOperations, + StorageBackend regions) { this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; + this.regions = regions; } public String getInstanceArn() { return INSTANCE_ARN; } public String getIdentityStoreId() { return IDENTITY_STORE_ID; } + public synchronized RegionMetadata addRegion(JsonNode request) { + requireInstance(required(request, "InstanceArn")); + String regionName = validateRegionName(required(request, "RegionName")); + if (PRIMARY_REGION.equals(regionName) || regions.get(regionName).isPresent()) { + throw conflict("Region is already enabled for this IAM Identity Center instance: " + regionName); + } + if (regions.scan(key -> true).size() >= REGION_QUOTA - 1) { + throw quota("The IAM Identity Center Region quota has been exceeded."); + } + String addedDate = java.time.Instant.now().toString(); + RegionMetadata response = new RegionMetadata(regionName, "ADDING", addedDate, false); + regions.put(regionName, new RegionMetadata(regionName, "ACTIVE", addedDate, false)); + return response; + } + public PaginatedResult listPermissionSets(JsonNode request) { requireInstance(required(request, "InstanceArn")); return Pagination.paginate(permissionSets.scan(key -> true), PermissionSet::arn, @@ -286,6 +309,13 @@ private static void validateInlinePolicy(String policy) { } } + private static String validateRegionName(String value) { + if (value == null || value.length() > 32 || !REGION_NAME.matcher(value).matches()) { + throw validation("RegionName must be 1-32 characters and use an AWS Region name format."); + } + return value; + } + private static String validateAccountId(String value) { if (value == null || !value.matches("\\d{12}")) { throw validation("AWS account identifiers must contain 12 digits."); @@ -320,6 +350,7 @@ public void clear() { permissionSets.clear(); assignments.clear(); assignmentOperations.clear(); + regions.clear(); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/RegionMetadata.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/RegionMetadata.java new file mode 100644 index 0000000000..d3ba51ea6a --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/RegionMetadata.java @@ -0,0 +1,11 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record RegionMetadata( + String regionName, + String status, + String addedDate, + boolean primaryRegion) { +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index beba7a53ef..cb7c83a2df 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -50,6 +50,32 @@ void listInstances_isStableAcrossCalls() { org.junit.jupiter.api.Assertions.assertEquals(firstArn, secondArn); } + @Test + void addRegionReturnsAddingAndRejectsDuplicate() { + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\",\"RegionName\":\"eu-west-2\"}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.AddRegion") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("Status", equalTo("ADDING")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.AddRegion") + .body(request) + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ConflictException")); + } + @Test void unknownAction_returnsUnknownOperationException() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 9af1fe7b15..3a5b1147ad 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,7 +29,46 @@ void setUp() { service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), - new InMemoryStorage()); + new InMemoryStorage(), + new InMemoryStorage()); + } + + @Test + void addRegionValidatesAndRejectsDuplicates() { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("RegionName", "us-west-2"); + + RegionMetadata created = service.addRegion(request); + assertEquals("us-west-2", created.regionName()); + assertEquals("ADDING", created.status()); + assertFalse(created.primaryRegion()); + assertNotNull(created.addedDate()); + + assertError("ConflictException", () -> service.addRegion(request)); + + ObjectNode primaryRegion = request.deepCopy(); + primaryRegion.put("RegionName", "us-east-1"); + assertError("ConflictException", () -> service.addRegion(primaryRegion)); + + ObjectNode invalidRegion = request.deepCopy(); + invalidRegion.put("RegionName", "invalid"); + assertError("ValidationException", () -> service.addRegion(invalidRegion)); + } + + @Test + void addRegionEnforcesTheDocumentedSixRegionQuotaIncludingPrimary() { + for (String region : java.util.List.of("us-west-1", "us-west-2", "eu-west-1", "eu-central-1", "ap-south-1")) { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("RegionName", region); + service.addRegion(request); + } + + ObjectNode overQuota = mapper.createObjectNode(); + overQuota.put("InstanceArn", service.getInstanceArn()); + overQuota.put("RegionName", "ap-northeast-1"); + assertError("ServiceQuotaExceededException", () -> service.addRegion(overQuota)); } @Test From ec79870d768c0456dd554874082e41796f40b29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Tue, 8 Sep 2026 23:44:52 -0400 Subject: [PATCH 02/99] feat(ssoadmin): attach customer managed policies --- .../test/SsoAdminAccountAssignmentTest.java | 31 +++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 53 ++++++++++++++++--- .../model/CustomerManagedPolicyReference.java | 7 +++ .../ssoadmin/model/PermissionSet.java | 9 +++- .../ssoadmin/SsoAdminIntegrationTest.java | 24 +++++++++ .../ssoadmin/SsoAdminServiceTest.java | 40 ++++++++++++++ 8 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/CustomerManagedPolicyReference.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index b9f927d605..7f928e58d6 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -33,6 +33,37 @@ void addRegionUsesAwsSdk() { } } + @Test + @DisplayName("attaches a customer managed policy reference through the AWS SDK") + void customerManagedPolicyReferenceUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("FlociCustomerPolicyAdmins")) + .permissionSet() + .permissionSetArn(); + + var response = sso.attachCustomerManagedPolicyReferenceToPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .customerManagedPolicyReference(reference -> reference + .name("PlatformPolicy") + .path("/platform/"))); + + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.attachCustomerManagedPolicyReferenceToPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .customerManagedPolicyReference(reference -> reference + .name("platformpolicy") + .path("/platform/")))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 1f6e3f7da2..191e3af030 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -18,6 +18,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `UpdatePermissionSet` | Updates mutable permission-set settings. | | `ListManagedPoliciesInPermissionSet` | Lists attached AWS managed policies with AWS-compatible pagination. | | `AttachManagedPolicyToPermissionSet` | Attaches an AWS managed policy. | +| `AttachCustomerManagedPolicyReferenceToPermissionSet` | Attaches a customer managed IAM policy reference by name and path. | | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 47964163b4..09ab7088d8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -33,6 +33,7 @@ public Response handle(String action, JsonNode request, String callerAccountId) case "UpdatePermissionSet" -> updatePermissionSet(request); case "ListManagedPoliciesInPermissionSet" -> listManagedPolicies(request); case "AttachManagedPolicyToPermissionSet" -> attachManagedPolicy(request); + case "AttachCustomerManagedPolicyReferenceToPermissionSet" -> attachCustomerManagedPolicyReference(request); case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); @@ -108,6 +109,11 @@ private Response attachManagedPolicy(JsonNode request) { return Response.ok(mapper.createObjectNode()).build(); } + private Response attachCustomerManagedPolicyReference(JsonNode request) { + service.attachCustomerManagedPolicyReference(request); + return Response.ok().build(); + } + private Response detachManagedPolicy(JsonNode request) { service.detachPolicy(SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn"), SsoAdminService.required(request, "ManagedPolicyArn")); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index a4fb13ce4d..d0e7ed187e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -10,6 +10,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import jakarta.enterprise.context.ApplicationScoped; @@ -33,6 +34,8 @@ public class SsoAdminService implements Resettable { private static final Pattern PERMISSION_SET_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso:::permissionSet/(?:sso)?ins-[a-zA-Z0-9-.]{16}/ps-[a-zA-Z0-9-./]{16}"); private static final Pattern PRINCIPAL_ID = Pattern.compile("([0-9a-f]{10}-|)[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"); private static final Pattern MANAGED_POLICY_ARN = Pattern.compile("arn:aws:iam::aws:policy/.+"); + private static final Pattern CUSTOMER_MANAGED_POLICY_NAME = Pattern.compile("[\\w+=,.@-]+"); + private static final Pattern CUSTOMER_MANAGED_POLICY_PATH = Pattern.compile("((/[A-Za-z0-9\\.,\\+@=_-]+)*)/"); private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; @@ -117,7 +120,7 @@ public synchronized PermissionSet createPermissionSet(JsonNode request) { } String arn = "arn:aws:sso:::permissionSet/ssoins-7223b02a5d9f7c8e/ps-" + shortId(); PermissionSet permissionSet = new PermissionSet(arn, name, description, sessionDuration, - new LinkedHashMap<>(), null); + new LinkedHashMap<>(), new LinkedHashMap<>(), null); permissionSets.put(arn, permissionSet); return permissionSet; } @@ -134,7 +137,8 @@ public synchronized PermissionSet updatePermissionSet(JsonNode request) { String session = request.has("SessionDuration") ? validateSession(required(request, "SessionDuration")) : current.sessionDuration(); PermissionSet updated = new PermissionSet(current.arn(), current.name(), description, session, - new LinkedHashMap<>(current.managedPolicies()), current.inlinePolicy()); + new LinkedHashMap<>(current.managedPolicies()), + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy()); permissionSets.put(updated.arn(), updated); return updated; } @@ -145,13 +149,40 @@ public synchronized void attachPolicy(String instanceArn, String arn, String pol if (current.managedPolicies().containsKey(policyArn)) { throw conflict("The managed policy is already attached to the permission set."); } - if (current.managedPolicies().size() >= MANAGED_POLICY_QUOTA) { - throw quota("A permission set can have at most 25 managed policies."); + if (managedPolicyCount(current) >= MANAGED_POLICY_QUOTA) { + throw quota("A permission set can have at most 25 AWS managed and customer managed policies."); } current.managedPolicies().put(policyArn, policyArn.substring(policyArn.lastIndexOf('/') + 1)); permissionSets.put(arn, current); } + public synchronized void attachCustomerManagedPolicyReference(JsonNode request) { + PermissionSet current = getPermissionSet(required(request, "InstanceArn"), required(request, "PermissionSetArn")); + JsonNode reference = request.get("CustomerManagedPolicyReference"); + if (reference == null || !reference.isObject()) { + throw validation("CustomerManagedPolicyReference must be an object."); + } + String name = required(reference, "Name"); + if (name.length() > 128 || !CUSTOMER_MANAGED_POLICY_NAME.matcher(name).matches()) { + throw validation("CustomerManagedPolicyReference.Name is invalid."); + } + String path = text(reference, "Path"); + if (path == null) { + path = "/"; + } else if (path.length() > 512 || !CUSTOMER_MANAGED_POLICY_PATH.matcher(path).matches()) { + throw validation("CustomerManagedPolicyReference.Path is invalid."); + } + String key = customerManagedPolicyKey(name, path); + if (current.customerManagedPolicies().containsKey(key)) { + throw conflict("The customer managed policy reference is already attached to the permission set."); + } + if (managedPolicyCount(current) >= MANAGED_POLICY_QUOTA) { + throw quota("A permission set can have at most 25 AWS managed and customer managed policies."); + } + current.customerManagedPolicies().put(key, new CustomerManagedPolicyReference(name, path)); + permissionSets.put(current.arn(), current); + } + public synchronized void detachPolicy(String instanceArn, String arn, String policyArn) { PermissionSet current = getPermissionSet(instanceArn, arn); validateManagedPolicyArn(policyArn); @@ -165,13 +196,15 @@ public synchronized void putInlinePolicy(String instanceArn, String arn, String PermissionSet current = getPermissionSet(instanceArn, arn); validateInlinePolicy(policy); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), - current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), policy)); + current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), + new LinkedHashMap<>(current.customerManagedPolicies()), policy)); } public synchronized void deleteInlinePolicy(String instanceArn, String arn) { PermissionSet current = getPermissionSet(instanceArn, arn); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), - current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), null)); + current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), + new LinkedHashMap<>(current.customerManagedPolicies()), null)); } public PaginatedResult listAssignments(JsonNode request) { @@ -309,6 +342,14 @@ private static void validateInlinePolicy(String policy) { } } + private static int managedPolicyCount(PermissionSet permissionSet) { + return permissionSet.managedPolicies().size() + permissionSet.customerManagedPolicies().size(); + } + + private static String customerManagedPolicyKey(String name, String path) { + return name.toLowerCase(java.util.Locale.ROOT) + "\n" + path; + } + private static String validateRegionName(String value) { if (value == null || value.length() > 32 || !REGION_NAME.matcher(value).matches()) { throw validation("RegionName must be 1-32 characters and use an AWS Region name format."); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/CustomerManagedPolicyReference.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/CustomerManagedPolicyReference.java new file mode 100644 index 0000000000..0a8c63e904 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/CustomerManagedPolicyReference.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record CustomerManagedPolicyReference(String name, String path) { +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java index 5f2fe242ae..0e381c2e64 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java @@ -6,4 +6,11 @@ @RegisterForReflection public record PermissionSet(String arn, String name, String description, String sessionDuration, - Map managedPolicies, String inlinePolicy) {} + Map managedPolicies, + Map customerManagedPolicies, + String inlinePolicy) { + public PermissionSet { + managedPolicies = managedPolicies == null ? new java.util.LinkedHashMap<>() : managedPolicies; + customerManagedPolicies = customerManagedPolicies == null ? new java.util.LinkedHashMap<>() : customerManagedPolicies; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index cb7c83a2df..85f4a314c8 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -76,6 +76,30 @@ void addRegionReturnsAddingAndRejectsDuplicate() { .body("__type", org.hamcrest.Matchers.containsString("ConflictException")); } + @Test + void attachCustomerManagedPolicyReferenceReturnsAnEmptyAwsResponse() { + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\",\"Name\":\"CustomerPolicyIntegration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.AttachCustomerManagedPolicyReferenceToPermissionSet") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"CustomerManagedPolicyReference\":{\"Name\":\"PlatformPolicy\",\"Path\":\"/platform/\"}}") + .when() + .post("/") + .then() + .statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + } + @Test void unknownAction_returnsUnknownOperationException() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 3a5b1147ad..086e58f57c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -71,6 +71,46 @@ void addRegionEnforcesTheDocumentedSixRegionQuotaIncludingPrimary() { assertError("ServiceQuotaExceededException", () -> service.addRegion(overQuota)); } + @Test + void attachesCustomerManagedPolicyReferencesWithAwsValidationRules() { + PermissionSet permissionSet = createPermissionSet("CustomerPolicyAdmins"); + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("PermissionSetArn", permissionSet.arn()); + request.putObject("CustomerManagedPolicyReference").put("Name", "PlatformPolicy"); + + service.attachCustomerManagedPolicyReference(request); + + PermissionSet stored = service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()); + assertEquals(1, stored.customerManagedPolicies().size()); + assertEquals("/", stored.customerManagedPolicies().values().iterator().next().path()); + + ObjectNode caseInsensitiveDuplicate = request.deepCopy(); + caseInsensitiveDuplicate.withObject("CustomerManagedPolicyReference").put("Name", "platformpolicy"); + assertError("ConflictException", () -> service.attachCustomerManagedPolicyReference(caseInsensitiveDuplicate)); + + ObjectNode invalidPath = request.deepCopy(); + invalidPath.withObject("CustomerManagedPolicyReference").put("Name", "OtherPolicy").put("Path", "missing-slash"); + assertError("ValidationException", () -> service.attachCustomerManagedPolicyReference(invalidPath)); + } + + @Test + void customerManagedPoliciesShareTheDocumentedManagedPolicyQuota() { + PermissionSet permissionSet = createPermissionSet("QuotaPolicyAdmins"); + for (int i = 0; i < 24; i++) { + service.attachPolicy(service.getInstanceArn(), permissionSet.arn(), + "arn:aws:iam::aws:policy/TestPolicy" + i); + } + ObjectNode customer = mapper.createObjectNode(); + customer.put("InstanceArn", service.getInstanceArn()); + customer.put("PermissionSetArn", permissionSet.arn()); + customer.putObject("CustomerManagedPolicyReference").put("Name", "CustomerPolicy"); + service.attachCustomerManagedPolicyReference(customer); + + assertError("ServiceQuotaExceededException", () -> service.attachPolicy(service.getInstanceArn(), permissionSet.arn(), + "arn:aws:iam::aws:policy/OverQuotaPolicy")); + } + @Test void managedPolicyPaginationHonorsMaxResultsAndNextToken() { PermissionSet permissionSet = createPermissionSet("PlatformAdmins"); From e5a12a7d43a35a54b53629c63518d810d894667e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Tue, 8 Sep 2026 23:51:32 -0400 Subject: [PATCH 03/99] feat(ssoadmin): create applications --- .../test/SsoAdminAccountAssignmentTest.java | 40 +++++ docs/services/ssoadmin.md | 1 + .../core/common/AwsJson11Controller.java | 2 +- .../ssoadmin/SsoAdminJsonHandler.java | 13 +- .../services/ssoadmin/SsoAdminService.java | 161 +++++++++++++++++- .../model/ApplicationPortalOptions.java | 7 + .../model/ApplicationSignInOptions.java | 7 + .../ssoadmin/model/SsoApplication.java | 25 +++ .../ssoadmin/SsoAdminIntegrationTest.java | 32 ++++ .../ssoadmin/SsoAdminServiceTest.java | 48 +++++- 10 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationPortalOptions.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationSignInOptions.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoApplication.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 7f928e58d6..090219105a 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -64,6 +64,46 @@ void customerManagedPolicyReferenceUsesAwsSdk() { } } + @Test + @DisplayName("creates OAuth applications through the AWS SDK") + void createApplicationUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + var created = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("Floci OAuth SDK") + .clientToken("sdk-create-application-token") + .status("DISABLED") + .portalOptions(options -> options + .visibility("ENABLED") + .signInOptions(signIn -> signIn + .origin("APPLICATION") + .applicationUrl("https://example.com/login"))) + .tags(tag -> tag.key("Environment").value("test"))); + + assertThat(created.applicationArn()).matches( + "arn:aws:sso::000000000000:application/ssoins-7223b02a5d9f7c8e/apl-[0-9a-f]{16}"); + assertThat(created.identityStoreArn()) + .isEqualTo("arn:aws:identitystore::000000000000:identitystore/d-9067f2a3c1"); + var replay = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("Floci OAuth SDK") + .clientToken("sdk-create-application-token") + .status("DISABLED") + .portalOptions(options -> options + .visibility("ENABLED") + .signInOptions(signIn -> signIn + .origin("APPLICATION") + .applicationUrl("https://example.com/login"))) + .tags(tag -> tag.key("Environment").value("test"))); + assertThat(replay.applicationArn()).isEqualTo(created.applicationArn()); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 191e3af030..9d17730eed 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -12,6 +12,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | +| `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java b/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java index 4d5a867dc2..fe6224fab7 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/AwsJson11Controller.java @@ -322,7 +322,7 @@ public Response handle( // action is authorized against the calling account, so pass that instead of region. case "organizations" -> organizationsJsonHandler.handle(action, request, regionResolver.getAccountId()); - case "sso" -> ssoAdminJsonHandler.handle(action, request, regionResolver.getAccountId()); + case "sso" -> ssoAdminJsonHandler.handle(action, request, regionResolver.getAccountId(), region); case "identitystore" -> identityStoreJsonHandler.handle(action, request); case "budgets" -> budgetsJsonHandler.handle(action, request, regionResolver.getAccountId()); case "servicequotas" -> serviceQuotasJsonHandler.handle( diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 09ab7088d8..d629bf6b35 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -8,6 +8,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; @@ -23,10 +24,11 @@ public SsoAdminJsonHandler(SsoAdminService service, ObjectMapper mapper) { this.mapper = mapper; } - public Response handle(String action, JsonNode request, String callerAccountId) { + public Response handle(String action, JsonNode request, String callerAccountId, String region) { return switch (action) { case "ListInstances" -> listInstances(callerAccountId); case "AddRegion" -> addRegion(request); + case "CreateApplication" -> createApplication(request, callerAccountId, region); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -60,6 +62,15 @@ private Response addRegion(JsonNode request) { return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); } + private Response createApplication(JsonNode request, String callerAccountId, String region) { + SsoApplication application = service.createApplication(request, callerAccountId, region); + ObjectNode response = mapper.createObjectNode(); + response.put("ApplicationArn", application.applicationArn()); + response.put("IdentityStoreArn", application.identityStoreArn()); + response.put("InstanceArn", application.instanceArn()); + return Response.ok(response).build(); + } + private Response listPermissionSets(JsonNode request) { var page = service.listPermissionSets(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index d0e7ed187e..7421aef99e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -10,9 +10,12 @@ import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; +import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -37,9 +40,15 @@ public class SsoAdminService implements Resettable { private static final Pattern CUSTOMER_MANAGED_POLICY_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final Pattern CUSTOMER_MANAGED_POLICY_PATH = Pattern.compile("((/[A-Za-z0-9\\.,\\+@=_-]+)*)/"); private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); + private static final Pattern APPLICATION_PROVIDER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::aws:applicationProvider/[a-zA-Z0-9-/]+"); + private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); + private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); + private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); + private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; private static final int MANAGED_POLICY_QUOTA = 25; + private static final int APPLICATION_QUOTA = 7000; private static final int MAX_INLINE_POLICY_BYTES = 32_768; private static final int MAX_INLINE_NON_WHITESPACE = 10_240; private static final Set PRINCIPAL_TYPES = Set.of("USER", "GROUP"); @@ -48,6 +57,8 @@ public class SsoAdminService implements Resettable { private final StorageBackend assignments; private final StorageBackend assignmentOperations; private final StorageBackend regions; + private final StorageBackend applications; + private final StorageBackend applicationClientTokens; @Inject public SsoAdminService(StorageFactory storageFactory) { @@ -55,22 +66,85 @@ public SsoAdminService(StorageFactory storageFactory) { storageFactory.create("ssoadmin", "ssoadmin-permission-sets.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {}), - storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {})); + storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {})); } SsoAdminService(StorageBackend permissionSets, StorageBackend assignments, StorageBackend assignmentOperations, - StorageBackend regions) { + StorageBackend regions, + StorageBackend applications, + StorageBackend applicationClientTokens) { this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; this.regions = regions; + this.applications = applications; + this.applicationClientTokens = applicationClientTokens; } public String getInstanceArn() { return INSTANCE_ARN; } public String getIdentityStoreId() { return IDENTITY_STORE_ID; } + public synchronized SsoApplication createApplication(JsonNode request, String callerAccountId, String region) { + requireInstance(required(request, "InstanceArn")); + validateAccountId(callerAccountId); + validateRegionName(region); + String providerArn = required(request, "ApplicationProviderArn"); + if (providerArn.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(providerArn).matches()) { + throw validation("ApplicationProviderArn is invalid."); + } + if (!CUSTOM_APPLICATION_PROVIDER_ARN.equals(providerArn)) { + throw notFound("Application provider not found: " + providerArn); + } + String name = required(request, "Name"); + if (name.length() > 100) { + throw validation("Name must be between 1 and 100 characters."); + } + String description = optionalString(request, "Description", 1, 128); + String status = text(request, "Status"); + if (status == null) { + status = "ENABLED"; + } else if (!Set.of("ENABLED", "DISABLED").contains(status)) { + throw validation("Status must be ENABLED or DISABLED."); + } + ApplicationPortalOptions portalOptions = parsePortalOptions(request.get("PortalOptions")); + Map tags = parseTags(request.get("Tags")); + String clientToken = text(request, "ClientToken"); + if (clientToken != null && (clientToken.length() > 64 || !CLIENT_TOKEN.matcher(clientToken).matches())) { + throw validation("ClientToken must be between 1 and 64 visible ASCII characters."); + } + + String tokenKey = clientToken == null ? null : callerAccountId + "::" + region + "::" + clientToken; + if (tokenKey != null) { + var existingArn = applicationClientTokens.get(tokenKey); + if (existingArn.isPresent()) { + SsoApplication existing = applications.get(existingArn.get()).orElse(null); + if (existing != null && applicationMatches(existing, providerArn, description, name, portalOptions, status, tags)) { + return existing; + } + throw new AwsException("IdempotentParameterMismatch", "ClientToken was reused with different request parameters.", 400); + } + } + if (applications.scan(a -> true).stream().filter(a -> callerAccountId.equals(a.applicationAccount())).count() >= APPLICATION_QUOTA) { + throw quota("The IAM Identity Center application quota has been exceeded."); + } + + String applicationArn = "arn:aws:sso::" + callerAccountId + + ":application/ssoins-7223b02a5d9f7c8e/apl-" + shortId(); + String identityStoreArn = "arn:aws:identitystore::" + callerAccountId + ":identitystore/" + IDENTITY_STORE_ID; + SsoApplication application = new SsoApplication(callerAccountId, applicationArn, providerArn, + System.currentTimeMillis(), region, description, identityStoreArn, INSTANCE_ARN, name, + portalOptions, status, tags); + applications.put(applicationArn, application); + if (tokenKey != null) { + applicationClientTokens.put(tokenKey, applicationArn); + } + return application; + } + public synchronized RegionMetadata addRegion(JsonNode request) { requireInstance(required(request, "InstanceArn")); String regionName = validateRegionName(required(request, "RegionName")); @@ -342,6 +416,87 @@ private static void validateInlinePolicy(String policy) { } } + private static boolean applicationMatches(SsoApplication application, String providerArn, String description, + String name, ApplicationPortalOptions portalOptions, String status, + Map tags) { + return java.util.Objects.equals(application.applicationProviderArn(), providerArn) + && java.util.Objects.equals(application.description(), description) + && java.util.Objects.equals(application.name(), name) + && java.util.Objects.equals(application.portalOptions(), portalOptions) + && java.util.Objects.equals(application.status(), status) + && java.util.Objects.equals(application.tags(), tags); + } + + private static ApplicationPortalOptions parsePortalOptions(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (!node.isObject()) { + throw validation("PortalOptions must be an object."); + } + String visibility = text(node, "Visibility"); + if (visibility != null && !Set.of("ENABLED", "DISABLED").contains(visibility)) { + throw validation("PortalOptions.Visibility must be ENABLED or DISABLED."); + } + JsonNode signIn = node.get("SignInOptions"); + ApplicationSignInOptions signInOptions = null; + if (signIn != null && !signIn.isNull()) { + if (!signIn.isObject()) { + throw validation("PortalOptions.SignInOptions must be an object."); + } + String origin = required(signIn, "Origin"); + if (!Set.of("IDENTITY_CENTER", "APPLICATION").contains(origin)) { + throw validation("SignInOptions.Origin must be IDENTITY_CENTER or APPLICATION."); + } + String applicationUrl = text(signIn, "ApplicationUrl"); + if ("APPLICATION".equals(origin) && applicationUrl == null) { + throw validation("SignInOptions.ApplicationUrl is required when Origin is APPLICATION."); + } + if (applicationUrl != null && (applicationUrl.length() > 512 || !APPLICATION_URL.matcher(applicationUrl).matches())) { + throw validation("SignInOptions.ApplicationUrl is invalid."); + } + signInOptions = new ApplicationSignInOptions(origin, applicationUrl); + } + return new ApplicationPortalOptions(visibility, signInOptions); + } + + private static String optionalString(JsonNode request, String field, int min, int max) { + if (request == null || !request.has(field) || request.get(field).isNull()) { + return null; + } + String value = text(request, field); + if (value == null || value.length() < min || value.length() > max) { + throw validation(field + " length is invalid."); + } + return value; + } + + private static Map parseTags(JsonNode node) { + Map tags = new LinkedHashMap<>(); + if (node == null || node.isNull()) { + return tags; + } + if (!node.isArray() || node.size() > 75) { + throw validation("Tags must contain at most 75 entries."); + } + for (JsonNode tag : node) { + if (!tag.isObject()) { + throw validation("Each tag must be an object."); + } + String key = required(tag, "Key"); + JsonNode valueNode = tag.get("Value"); + String value = valueNode != null && valueNode.isTextual() ? valueNode.textValue() : null; + if (key.length() > 128 || !TAG_VALUE.matcher(key).matches() + || value == null || value.length() > 256 || !TAG_VALUE.matcher(value).matches()) { + throw validation("Tag key or value is invalid."); + } + if (tags.putIfAbsent(key, value) != null) { + throw validation("Duplicate tag key: " + key); + } + } + return tags; + } + private static int managedPolicyCount(PermissionSet permissionSet) { return permissionSet.managedPolicies().size() + permissionSet.customerManagedPolicies().size(); } @@ -392,6 +547,8 @@ public void clear() { assignments.clear(); assignmentOperations.clear(); regions.clear(); + applications.clear(); + applicationClientTokens.clear(); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationPortalOptions.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationPortalOptions.java new file mode 100644 index 0000000000..0f32e79163 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationPortalOptions.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ApplicationPortalOptions(String visibility, ApplicationSignInOptions signInOptions) { +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationSignInOptions.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationSignInOptions.java new file mode 100644 index 0000000000..286577b791 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationSignInOptions.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ApplicationSignInOptions(String origin, String applicationUrl) { +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoApplication.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoApplication.java new file mode 100644 index 0000000000..70fad00906 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoApplication.java @@ -0,0 +1,25 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RegisterForReflection +public record SsoApplication( + String applicationAccount, + String applicationArn, + String applicationProviderArn, + long createdDateEpochMillis, + String createdFrom, + String description, + String identityStoreArn, + String instanceArn, + String name, + ApplicationPortalOptions portalOptions, + String status, + Map tags) { + public SsoApplication { + tags = tags == null ? new LinkedHashMap<>() : tags; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 85f4a314c8..0f5f001acc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -100,6 +100,38 @@ void attachCustomerManagedPolicyReferenceReturnsAnEmptyAwsResponse() { .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void createApplicationReturnsAwsIdentifiersAndIsIdempotent() { + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Integration OAuth\",\"ClientToken\":\"integration-token\"}"; + + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("InstanceArn", equalTo("arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e")) + .body("IdentityStoreArn", equalTo("arn:aws:identitystore::000000000000:identitystore/d-9067f2a3c1")) + .body("ApplicationArn", matchesPattern("arn:aws:sso::000000000000:application/ssoins-7223b02a5d9f7c8e/apl-[0-9a-f]{16}")) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("ApplicationArn", equalTo(applicationArn)); + } + @Test void unknownAction_returnsUnknownOperationException() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 086e58f57c..ae135bbcdc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -8,6 +8,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; +import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,7 +31,52 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), - new InMemoryStorage()); + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage()); + } + + @Test + void createApplicationSupportsOAuthProviderPortalOptionsTagsAndIdempotency() { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + request.put("Name", "Platform Portal"); + request.put("Description", "Platform OAuth application"); + request.put("ClientToken", "token-123456"); + request.put("Status", "DISABLED"); + request.putObject("PortalOptions").put("Visibility", "ENABLED") + .putObject("SignInOptions").put("Origin", "APPLICATION").put("ApplicationUrl", "https://example.com/login"); + request.putArray("Tags").addObject().put("Key", "Environment").put("Value", "dev"); + + SsoApplication created = service.createApplication(request, ACCOUNT_ID, "us-east-1"); + assertTrue(created.applicationArn().matches("arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-[0-9a-f]{16}")); + assertEquals("arn:aws:identitystore::123456789012:identitystore/d-9067f2a3c1", created.identityStoreArn()); + assertEquals("DISABLED", created.status()); + assertEquals("APPLICATION", created.portalOptions().signInOptions().origin()); + assertEquals(created.applicationArn(), service.createApplication(request, ACCOUNT_ID, "us-east-1").applicationArn()); + + ObjectNode mismatch = request.deepCopy(); + mismatch.put("Name", "Different Name"); + assertError("IdempotentParameterMismatch", () -> service.createApplication(mismatch, ACCOUNT_ID, "us-east-1")); + } + + @Test + void createApplicationRejectsUnsupportedProvidersAndInvalidPortalOptions() { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + request.put("Name", "Portal"); + request.putObject("PortalOptions").putObject("SignInOptions").put("Origin", "APPLICATION"); + assertError("ValidationException", () -> service.createApplication(request, ACCOUNT_ID, "us-east-1")); + + request.remove("PortalOptions"); + request.putArray("Tags").addObject().put("Key", "bad*").put("Value", "value"); + assertError("ValidationException", () -> service.createApplication(request, ACCOUNT_ID, "us-east-1")); + + request.remove("Tags"); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/aws-managed"); + assertError("ResourceNotFoundException", () -> service.createApplication(request, ACCOUNT_ID, "us-east-1")); } @Test From 3b6d24c0383546ab4992028c755c86bb5ac0410a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Tue, 8 Sep 2026 23:54:43 -0400 Subject: [PATCH 04/99] feat(ssoadmin): create application assignments --- .../test/SsoAdminAccountAssignmentTest.java | 26 +++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 51 +++++++++++++++++- .../ssoadmin/model/ApplicationAssignment.java | 7 +++ .../ssoadmin/SsoAdminIntegrationTest.java | 28 ++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 54 ++++++++++++++++++- 7 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAssignment.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 090219105a..fee937ac31 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -104,6 +104,32 @@ void createApplicationUsesAwsSdk() { } } + @Test + @DisplayName("creates application assignments through the AWS SDK") + void createApplicationAssignmentUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SDK Assignment App") + .clientToken("sdk-assignment-app-token")).applicationArn(); + + var response = sso.createApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId("11111111-2222-3333-4444-555555555555") + .principalType("USER")); + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.createApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId("11111111-2222-3333-4444-555555555555") + .principalType("USER"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 9d17730eed..fc5e47a94c 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -13,6 +13,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListInstances` | Lists the local IAM Identity Center instance. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | +| `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index d629bf6b35..3bf4792aab 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -29,6 +29,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListInstances" -> listInstances(callerAccountId); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); + case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -62,6 +63,11 @@ private Response addRegion(JsonNode request) { return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); } + private Response createApplicationAssignment(JsonNode request) { + service.createApplicationAssignment(request); + return Response.ok().build(); + } + private Response createApplication(JsonNode request, String callerAccountId, String region) { SsoApplication application = service.createApplication(request, callerAccountId, region); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 7421aef99e..44b020033f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -10,6 +10,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; @@ -41,6 +42,7 @@ public class SsoAdminService implements Resettable { private static final Pattern CUSTOMER_MANAGED_POLICY_PATH = Pattern.compile("((/[A-Za-z0-9\\.,\\+@=_-]+)*)/"); private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); private static final Pattern APPLICATION_PROVIDER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::aws:applicationProvider/[a-zA-Z0-9-/]+"); + private static final Pattern APPLICATION_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); @@ -49,6 +51,7 @@ public class SsoAdminService implements Resettable { private static final int REGION_QUOTA = 6; private static final int MANAGED_POLICY_QUOTA = 25; private static final int APPLICATION_QUOTA = 7000; + private static final int APPLICATION_GROUP_ASSIGNMENT_QUOTA = 100; private static final int MAX_INLINE_POLICY_BYTES = 32_768; private static final int MAX_INLINE_NON_WHITESPACE = 10_240; private static final Set PRINCIPAL_TYPES = Set.of("USER", "GROUP"); @@ -59,6 +62,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend regions; private final StorageBackend applications; private final StorageBackend applicationClientTokens; + private final StorageBackend applicationAssignments; @Inject public SsoAdminService(StorageFactory storageFactory) { @@ -68,7 +72,8 @@ public SsoAdminService(StorageFactory storageFactory) { storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), - storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {})); + storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {})); } SsoAdminService(StorageBackend permissionSets, @@ -76,18 +81,43 @@ public SsoAdminService(StorageFactory storageFactory) { StorageBackend assignmentOperations, StorageBackend regions, StorageBackend applications, - StorageBackend applicationClientTokens) { + StorageBackend applicationClientTokens, + StorageBackend applicationAssignments) { this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; this.regions = regions; this.applications = applications; this.applicationClientTokens = applicationClientTokens; + this.applicationAssignments = applicationAssignments; } public String getInstanceArn() { return INSTANCE_ARN; } public String getIdentityStoreId() { return IDENTITY_STORE_ID; } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String principalId = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + String key = applicationAssignmentKey(applicationArn, principalId, principalType); + if (applicationAssignments.get(key).isPresent()) { + throw conflict("The application assignment already exists."); + } + if ("GROUP".equals(principalType) + && applicationAssignments.scan(ignored -> true).stream() + .filter(a -> applicationArn.equals(a.applicationArn()) && "GROUP".equals(a.principalType())) + .count() >= APPLICATION_GROUP_ASSIGNMENT_QUOTA) { + throw quota("An application can have at most 100 directly assigned groups."); + } + ApplicationAssignment assignment = new ApplicationAssignment(applicationArn, principalId, principalType); + applicationAssignments.put(key, assignment); + return assignment; + } + public synchronized SsoApplication createApplication(JsonNode request, String callerAccountId, String region) { requireInstance(required(request, "InstanceArn")); validateAccountId(callerAccountId); @@ -416,6 +446,22 @@ private static void validateInlinePolicy(String policy) { } } + SsoApplication getApplication(String applicationArn) { + validateApplicationArn(applicationArn); + return applications.get(applicationArn).orElseThrow(() -> notFound("Application not found: " + applicationArn)); + } + + private static String validateApplicationArn(String arn) { + if (arn == null || arn.length() > 1224 || !APPLICATION_ARN.matcher(arn).matches()) { + throw validation("ApplicationArn is invalid."); + } + return arn; + } + + private static String applicationAssignmentKey(String applicationArn, String principalId, String principalType) { + return applicationArn + "::" + principalType + "::" + principalId; + } + private static boolean applicationMatches(SsoApplication application, String providerArn, String description, String name, ApplicationPortalOptions portalOptions, String status, Map tags) { @@ -549,6 +595,7 @@ public void clear() { regions.clear(); applications.clear(); applicationClientTokens.clear(); + applicationAssignments.clear(); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAssignment.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAssignment.java new file mode 100644 index 0000000000..cf0b2b7f06 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAssignment.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ApplicationAssignment(String applicationArn, String principalId, String principalType) { +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 0f5f001acc..11f4ec36aa 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -132,6 +132,34 @@ void createApplicationReturnsAwsIdentifiersAndIsIdempotent() { .body("ApplicationArn", equalTo(applicationArn)); } + @Test + void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { + String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Assignment Integration\",\"ClientToken\":\"assignment-integration-token\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(appRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + String assignmentRequest = "{\"ApplicationArn\":\"" + applicationArn + "\"," + + "\"PrincipalId\":\"11111111-2222-3333-4444-555555555555\",\"PrincipalType\":\"USER\"}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplicationAssignment") + .body(assignmentRequest) + .when() + .post("/") + .then() + .statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + } + @Test void unknownAction_returnsUnknownOperationException() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index ae135bbcdc..b54c6c4415 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -6,6 +6,7 @@ import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; @@ -33,7 +34,49 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), - new InMemoryStorage()); + new InMemoryStorage(), + new InMemoryStorage()); + } + + @Test + void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { + SsoApplication application = createApplication("Assignment App", "assignment-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("PrincipalId", PRINCIPAL_ID); + request.put("PrincipalType", "GROUP"); + + ApplicationAssignment assignment = service.createApplicationAssignment(request); + assertEquals(application.applicationArn(), assignment.applicationArn()); + assertEquals(PRINCIPAL_ID, assignment.principalId()); + assertEquals("GROUP", assignment.principalType()); + assertError("ConflictException", () -> service.createApplicationAssignment(request)); + + ObjectNode invalidPrincipalType = request.deepCopy(); + invalidPrincipalType.put("PrincipalType", "ROLE"); + invalidPrincipalType.put("PrincipalId", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + assertError("ValidationException", () -> service.createApplicationAssignment(invalidPrincipalType)); + + ObjectNode missingApplication = request.deepCopy(); + missingApplication.put("ApplicationArn", "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.createApplicationAssignment(missingApplication)); + } + + @Test + void createApplicationAssignmentEnforcesTheDocumentedGroupQuota() { + SsoApplication application = createApplication("Group Quota App", "group-quota-app-token"); + for (int i = 0; i < 100; i++) { + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("PrincipalId", "00000000-0000-0000-0000-" + String.format("%012x", i)); + request.put("PrincipalType", "GROUP"); + service.createApplicationAssignment(request); + } + ObjectNode overQuota = mapper.createObjectNode(); + overQuota.put("ApplicationArn", application.applicationArn()); + overQuota.put("PrincipalId", "00000000-0000-0000-0000-000000000100"); + overQuota.put("PrincipalType", "GROUP"); + assertError("ServiceQuotaExceededException", () -> service.createApplicationAssignment(overQuota)); } @Test @@ -233,6 +276,15 @@ void clearRemovesPersistedServiceState() { () -> service.getAssignmentOperation(service.getInstanceArn(), operation.requestId())); } + private SsoApplication createApplication(String name, String clientToken) { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + request.put("Name", name); + request.put("ClientToken", clientToken); + return service.createApplication(request, ACCOUNT_ID, "us-east-1"); + } + private PermissionSet createPermissionSet(String name) { ObjectNode request = mapper.createObjectNode(); request.put("InstanceArn", service.getInstanceArn()); From 4be6aee43d1b57dfeba64d937a9cb469ddce94fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 08:39:03 -0400 Subject: [PATCH 05/99] feat(ssoadmin): create account instances --- .../java/com/floci/test/TestFixtures.java | 8 + .../test/SsoAdminAccountAssignmentTest.java | 35 +++++ docs/services/ssoadmin.md | 1 + .../organizations/OrganizationsService.java | 6 + .../ssoadmin/SsoAdminJsonHandler.java | 36 ++++- .../services/ssoadmin/SsoAdminService.java | 143 +++++++++++++++++- .../services/ssoadmin/model/SsoInstance.java | 23 +++ .../ssoadmin/SsoAdminIntegrationTest.java | 54 ++++++- .../ssoadmin/SsoAdminServiceTest.java | 92 ++++++++++- 9 files changed, 383 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoInstance.java diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index a74b9b0db9..e8040a4d43 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java @@ -317,6 +317,14 @@ public static SsoAdminClient ssoAdminClient() { .build(); } + public static SsoAdminClient ssoAdminClient(String accountId) { + return SsoAdminClient.builder() + .endpointOverride(ENDPOINT) + .region(REGION) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accountId, "test"))) + .build(); + } + public static IdentitystoreClient identityStoreClient() { return IdentitystoreClient.builder() .endpointOverride(ENDPOINT) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index fee937ac31..2d48a5e99e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -130,6 +130,41 @@ void createApplicationAssignmentUsesAwsSdk() { } } + @Test + @DisplayName("creates an account instance through the AWS SDK") + void createInstanceUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses an emulator-only account instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient("333344445555")) { + assertThat(sso.listInstances(request -> {}).instances()).isEmpty(); + String instanceArn = sso.createInstance(request -> request + .name("SdkAccountInstance") + .clientToken("sdk-create-instance") + .tags(tag -> tag.key("Environment").value("test"))) + .instanceArn(); + + var listed = sso.listInstances(request -> {}).instances(); + assertThat(listed).hasSize(1); + assertThat(listed.get(0).instanceArn()).isEqualTo(instanceArn); + assertThat(listed.get(0).ownerAccountId()).isEqualTo("333344445555"); + assertThat(listed.get(0).primaryRegion()).isEqualTo("us-east-1"); + } + } + + @Test + @DisplayName("models the singleton CreateInstance quota through the AWS SDK") + void createInstanceQuotaUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center fixture"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + sso.listInstances(request -> {}); + assertThatThrownBy(() -> sso.createInstance(request -> request + .name("SecondInstance") + .clientToken("sdk-create-instance-quota"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ServiceQuotaExceededException.class); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index fc5e47a94c..0f63d2e8ac 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -11,6 +11,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | Action | Description | | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | +| `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java b/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java index bf47793626..f1e98f98ff 100644 --- a/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java @@ -1350,6 +1350,12 @@ public Optional findManagementAccountForResource(String resourceId) { return Optional.empty(); } + public boolean isManagementAccount(String accountId) { + return findOrganizationForAccount(accountId) + .map(organization -> accountId.equals(organization.getMasterAccountId())) + .orElse(false); + } + private Optional findOrganizationForAccount(String accountId) { for (Organization organization : organizations.scanAllAccounts()) { if (accountId.equals(organization.getMasterAccountId())) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 3bf4792aab..f0c3cebca4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -27,6 +27,7 @@ public SsoAdminJsonHandler(SsoAdminService service, ObjectMapper mapper) { public Response handle(String action, JsonNode request, String callerAccountId, String region) { return switch (action) { case "ListInstances" -> listInstances(callerAccountId); + case "CreateInstance" -> createInstance(request, callerAccountId, region); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -49,15 +50,38 @@ public Response handle(String action, JsonNode request, String callerAccountId, private Response listInstances(String callerAccountId) { ObjectNode response = mapper.createObjectNode(); - ObjectNode instance = response.putArray("Instances").addObject(); - instance.put("InstanceArn", service.getInstanceArn()); - instance.put("IdentityStoreId", service.getIdentityStoreId()); - instance.put("Name", "floci-identity-center"); - instance.put("OwnerAccountId", callerAccountId); - instance.put("Status", "ACTIVE"); + ArrayNode instances = response.putArray("Instances"); + service.listInstances(callerAccountId).forEach(instance -> { + ObjectNode node = instances.addObject(); + node.put("InstanceArn", instance.instanceArn()); + node.put("IdentityStoreId", instance.identityStoreId()); + if (instance.name() != null) { + node.put("Name", instance.name()); + } + node.put("OwnerAccountId", instance.ownerAccountId()); + node.put("CreatedDate", instance.createdDateEpochMillis() / 1000.0d); + node.put("PrimaryRegion", instance.primaryRegion()); + ArrayNode regionNodes = node.putArray("Regions"); + service.listRegionsForInstance(instance).forEach(region -> { + ObjectNode regionNode = regionNodes.addObject(); + regionNode.put("RegionName", region.regionName()); + regionNode.put("Status", region.status()); + regionNode.put("IsPrimaryRegion", region.primaryRegion()); + regionNode.put("AddedDate", service.regionAddedDateEpochSeconds(region)); + }); + node.put("Status", instance.status()); + if (instance.statusReason() != null) { + node.put("StatusReason", instance.statusReason()); + } + }); return Response.ok(response).build(); } + private Response createInstance(JsonNode request, String callerAccountId, String region) { + var instance = service.createInstance(request, callerAccountId, region); + return Response.ok(mapper.createObjectNode().put("InstanceArn", instance.instanceArn())).build(); + } + private Response addRegion(JsonNode request) { var region = service.addRegion(request); return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 44b020033f..277f3408fe 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.PaginatedResult; import io.github.hectorvent.floci.core.common.Pagination; @@ -17,10 +18,13 @@ import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; +import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; +import io.github.hectorvent.floci.services.organizations.OrganizationsService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.time.Duration; +import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; @@ -46,6 +50,7 @@ public class SsoAdminService implements Resettable { private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); + private static final Pattern INSTANCE_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; @@ -63,9 +68,15 @@ public class SsoAdminService implements Resettable { private final StorageBackend applications; private final StorageBackend applicationClientTokens; private final StorageBackend applicationAssignments; + private final StorageBackend instances; + private final StorageBackend instanceClientTokens; + private final OrganizationsService organizationsService; + private final String defaultAccountId; + private final String defaultRegion; @Inject - public SsoAdminService(StorageFactory storageFactory) { + public SsoAdminService(StorageFactory storageFactory, OrganizationsService organizationsService, + EmulatorConfig config) { this( storageFactory.create("ssoadmin", "ssoadmin-permission-sets.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), @@ -73,7 +84,12 @@ public SsoAdminService(StorageFactory storageFactory) { storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), - storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {})); + storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), + organizationsService, + config.defaultAccountId(), + config.defaultRegion()); } SsoAdminService(StorageBackend permissionSets, @@ -82,7 +98,12 @@ public SsoAdminService(StorageFactory storageFactory) { StorageBackend regions, StorageBackend applications, StorageBackend applicationClientTokens, - StorageBackend applicationAssignments) { + StorageBackend applicationAssignments, + StorageBackend instances, + StorageBackend instanceClientTokens, + OrganizationsService organizationsService, + String defaultAccountId, + String defaultRegion) { this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; @@ -90,11 +111,93 @@ public SsoAdminService(StorageFactory storageFactory) { this.applications = applications; this.applicationClientTokens = applicationClientTokens; this.applicationAssignments = applicationAssignments; + this.instances = instances; + this.instanceClientTokens = instanceClientTokens; + this.organizationsService = organizationsService; + this.defaultAccountId = defaultAccountId; + this.defaultRegion = defaultRegion; + ensureBootstrapInstance(defaultAccountId, defaultRegion); } public String getInstanceArn() { return INSTANCE_ARN; } public String getIdentityStoreId() { return IDENTITY_STORE_ID; } + void ensureBootstrapInstance(String ownerAccountId, String region) { + if (instances.get(ownerAccountId).isEmpty()) { + instances.put(ownerAccountId, new SsoInstance(INSTANCE_ARN, IDENTITY_STORE_ID, "floci-identity-center", + ownerAccountId, region, System.currentTimeMillis(), "ACTIVE", null, false, + new LinkedHashMap<>())); + } + } + + public synchronized List listInstances(String callerAccountId) { + validateAccountId(callerAccountId); + if (callerAccountId.equals(defaultAccountId) && instances.get(callerAccountId).isEmpty()) { + ensureBootstrapInstance(callerAccountId, defaultRegion); + } + return instances.get(callerAccountId).map(List::of).orElseGet(List::of); + } + + public List listRegionsForInstance(SsoInstance instance) { + List result = new ArrayList<>(); + result.add(new RegionMetadata(instance.primaryRegion(), "ACTIVE", + java.time.Instant.ofEpochMilli(instance.createdDateEpochMillis()).toString(), true)); + if (!instance.accountInstance()) { + result.addAll(regions.scan(key -> true).stream() + .filter(region -> !instance.primaryRegion().equals(region.regionName())) + .sorted(Comparator.comparing(RegionMetadata::regionName)) + .toList()); + } + return result; + } + + public double regionAddedDateEpochSeconds(RegionMetadata region) { + return java.time.Instant.parse(region.addedDate()).toEpochMilli() / 1000.0d; + } + + public synchronized SsoInstance createInstance(JsonNode request, String callerAccountId, String region) { + validateAccountId(callerAccountId); + validateRegionName(region); + if (organizationsService.isManagementAccount(callerAccountId)) { + throw accessDenied("The AWS Organizations management account cannot create an account instance of IAM Identity Center."); + } + String name = optionalInstanceName(request); + Map tags = parseTags(request.get("Tags")); + JsonNode clientTokenNode = request == null ? null : request.get("ClientToken"); + String clientToken; + if (clientTokenNode == null || clientTokenNode.isNull()) { + clientToken = UUID.randomUUID().toString(); + } else if (!clientTokenNode.isTextual()) { + throw validation("ClientToken must be a string."); + } else { + clientToken = clientTokenNode.textValue(); + if (clientToken.length() > 64 || !CLIENT_TOKEN.matcher(clientToken).matches()) { + throw validation("ClientToken must be between 1 and 64 visible ASCII characters."); + } + } + String tokenKey = callerAccountId + "::" + clientToken; + var priorArn = instanceClientTokens.get(tokenKey); + if (priorArn.isPresent()) { + SsoInstance prior = findInstanceByArn(priorArn.get()); + if (prior != null && java.util.Objects.equals(prior.name(), name) + && java.util.Objects.equals(prior.tags(), tags)) { + return prior; + } + throw new AwsException("IdempotentParameterMismatch", + "ClientToken was reused with different request parameters.", 400); + } + if (instances.get(callerAccountId).isPresent()) { + throw quota("Only one IAM Identity Center instance can exist in an AWS account."); + } + String instanceArn = "arn:aws:sso:::instance/ssoins-" + shortId(); + String identityStoreId = "d-" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + SsoInstance instance = new SsoInstance(instanceArn, identityStoreId, name, callerAccountId, region, + System.currentTimeMillis(), "ACTIVE", null, true, tags); + instances.put(callerAccountId, instance); + instanceClientTokens.put(tokenKey, instanceArn); + return instance; + } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); @@ -506,6 +609,17 @@ private static ApplicationPortalOptions parsePortalOptions(JsonNode node) { return new ApplicationPortalOptions(visibility, signInOptions); } + private static String optionalInstanceName(JsonNode request) { + if (request == null || !request.has("Name") || request.get("Name").isNull()) { + return null; + } + String name = text(request, "Name"); + if (name == null || name.length() > 255 || !INSTANCE_NAME.matcher(name).matches()) { + throw validation("Name must be at most 255 characters and match [\\w+=,.@-]+."); + } + return name; + } + private static String optionalString(JsonNode request, String field, int min, int max) { if (request == null || !request.has(field) || request.get(field).isNull()) { return null; @@ -533,7 +647,8 @@ private static Map parseTags(JsonNode node) { JsonNode valueNode = tag.get("Value"); String value = valueNode != null && valueNode.isTextual() ? valueNode.textValue() : null; if (key.length() > 128 || !TAG_VALUE.matcher(key).matches() - || value == null || value.length() > 256 || !TAG_VALUE.matcher(value).matches()) { + || value == null || value.length() > 256 || !TAG_VALUE.matcher(value).matches() + || key.regionMatches(true, 0, "aws:", 0, 4)) { throw validation("Tag key or value is invalid."); } if (tags.putIfAbsent(key, value) != null) { @@ -581,10 +696,23 @@ private static String valueOr(JsonNode request, String field, String fallback) { private static AwsException validation(String message) { return new AwsException("ValidationException", message, 400); } private static AwsException conflict(String message) { return new AwsException("ConflictException", message, 400); } private static AwsException quota(String message) { return new AwsException("ServiceQuotaExceededException", message, 400); } - private static void requireInstance(String arn) { - if (arn == null || !arn.equals(INSTANCE_ARN)) { + private static AwsException accessDenied(String message) { return new AwsException("AccessDeniedException", message, 400); } + private SsoInstance requireInstance(String arn) { + SsoInstance instance = findInstanceByArn(arn); + if (instance == null) { throw notFound("IAM Identity Center instance not found: " + arn); } + return instance; + } + + private SsoInstance findInstanceByArn(String arn) { + if (arn == null) { + return null; + } + SsoInstance stored = instances.scan(key -> true).stream() + .filter(instance -> arn.equals(instance.instanceArn())) + .findFirst().orElse(null); + return stored; } @Override @@ -596,6 +724,9 @@ public void clear() { applications.clear(); applicationClientTokens.clear(); applicationAssignments.clear(); + instances.clear(); + instanceClientTokens.clear(); + ensureBootstrapInstance(defaultAccountId, defaultRegion); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoInstance.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoInstance.java new file mode 100644 index 0000000000..d7773acaeb --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/SsoInstance.java @@ -0,0 +1,23 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RegisterForReflection +public record SsoInstance( + String instanceArn, + String identityStoreId, + String name, + String ownerAccountId, + String primaryRegion, + long createdDateEpochMillis, + String status, + String statusReason, + boolean accountInstance, + Map tags) { + public SsoInstance { + tags = tags == null ? new LinkedHashMap<>() : tags; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 11f4ec36aa..c08f728744 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -160,6 +160,56 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { + String auth = "AWS4-HMAC-SHA256 Credential=222233334444/20260101/us-west-2/sso/aws4_request"; + String request = "{\"Name\":\"StandaloneInstance\",\"ClientToken\":\"instance-integration-token\"," + + "\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"test\"}]}"; + + String instanceArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.CreateInstance") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("InstanceArn", matchesPattern("arn:aws:sso:::instance/ssoins-[0-9a-f]{16}")) + .extract().path("InstanceArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.CreateInstance") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("InstanceArn", equalTo(instanceArn)); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.ListInstances") + .body("{}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Instances.size()", equalTo(1)) + .body("Instances[0].InstanceArn", equalTo(instanceArn)) + .body("Instances[0].Name", equalTo("StandaloneInstance")) + .body("Instances[0].OwnerAccountId", equalTo("222233334444")) + .body("Instances[0].PrimaryRegion", equalTo("us-west-2")) + .body("Instances[0].CreatedDate", org.hamcrest.Matchers.greaterThan(0.0f)) + .body("Instances[0].Regions.size()", equalTo(1)) + .body("Instances[0].Regions[0].RegionName", equalTo("us-west-2")) + .body("Instances[0].Regions[0].IsPrimaryRegion", equalTo(true)) + .body("Instances[0].Regions[0].Status", equalTo("ACTIVE")); + } + @Test void unknownAction_returnsUnknownOperationException() { given() @@ -175,7 +225,7 @@ void unknownAction_returnsUnknownOperationException() { } @Test - void listInstances_ownerAccountIdTracksTheCaller() { + void listInstancesReturnsEmptyForAnAccountWithoutAVisibleInstance() { given() .contentType("application/x-amz-json-1.1") .header("Authorization", @@ -186,7 +236,7 @@ void listInstances_ownerAccountIdTracksTheCaller() { .post("/") .then() .statusCode(200) - .body("Instances[0].OwnerAccountId", org.hamcrest.Matchers.equalTo("111122223333")); + .body("Instances.size()", org.hamcrest.Matchers.equalTo(0)); } private static String listInstancesArn() { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index b54c6c4415..722ff85e79 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -10,6 +10,8 @@ import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; +import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; +import io.github.hectorvent.floci.services.organizations.OrganizationsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,9 +27,11 @@ class SsoAdminServiceTest { private final ObjectMapper mapper = new ObjectMapper(); private SsoAdminService service; + private OrganizationsService organizationsService; @BeforeEach void setUp() { + organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), @@ -35,7 +39,77 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), - new InMemoryStorage()); + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + organizationsService, + ACCOUNT_ID, + "us-east-1"); + service.ensureBootstrapInstance(ACCOUNT_ID, "us-east-1"); + } + + @Test + void createInstancePersistsMetadataAndSupportsIdempotentReplay() { + SsoAdminService emptyService = emptyService(); + ObjectNode request = mapper.createObjectNode(); + request.put("Name", "AccountInstance"); + request.put("ClientToken", "create-instance-token"); + request.putArray("Tags").addObject().put("Key", "Environment").put("Value", "dev"); + + assertTrue(emptyService.listInstances(ACCOUNT_ID).isEmpty()); + + SsoInstance created = emptyService.createInstance(request, ACCOUNT_ID, "us-west-2"); + assertEquals(ACCOUNT_ID, created.ownerAccountId()); + assertEquals("us-west-2", created.primaryRegion()); + assertEquals("ACTIVE", created.status()); + assertTrue(created.accountInstance()); + assertTrue(created.instanceArn().matches("arn:aws:sso:::instance/ssoins-[0-9a-f]{16}")); + assertTrue(created.identityStoreId().matches("d-[0-9a-f]{10}")); + assertEquals(created.instanceArn(), emptyService.createInstance(request, ACCOUNT_ID, "us-west-2").instanceArn()); + assertEquals(1, emptyService.listInstances(ACCOUNT_ID).size()); + + ObjectNode mismatch = request.deepCopy(); + mismatch.put("Name", "DifferentName"); + assertError("IdempotentParameterMismatch", + () -> emptyService.createInstance(mismatch, ACCOUNT_ID, "us-west-2")); + } + + @Test + void createInstanceEnforcesSingletonAndValidatesInputs() { + ObjectNode duplicate = mapper.createObjectNode(); + duplicate.put("Name", "SecondInstance"); + assertError("ServiceQuotaExceededException", + () -> service.createInstance(duplicate, ACCOUNT_ID, "us-east-1")); + + SsoAdminService emptyService = emptyService(); + ObjectNode invalidName = mapper.createObjectNode(); + invalidName.put("Name", "bad name"); + assertError("ValidationException", + () -> emptyService.createInstance(invalidName, ACCOUNT_ID, "us-east-1")); + + ObjectNode invalidToken = mapper.createObjectNode(); + invalidToken.put("ClientToken", "bad token"); + assertError("ValidationException", + () -> emptyService.createInstance(invalidToken, ACCOUNT_ID, "us-east-1")); + + ObjectNode nonStringToken = mapper.createObjectNode(); + nonStringToken.put("ClientToken", 123); + assertError("ValidationException", + () -> emptyService.createInstance(nonStringToken, ACCOUNT_ID, "us-east-1")); + + ObjectNode reservedTag = mapper.createObjectNode(); + reservedTag.putArray("Tags").addObject().put("Key", "aws:reserved").put("Value", "x"); + assertError("ValidationException", + () -> emptyService.createInstance(reservedTag, ACCOUNT_ID, "us-east-1")); + } + + @Test + void createInstanceRejectsOrganizationsManagementAccounts() { + org.mockito.Mockito.when(organizationsService.isManagementAccount(ACCOUNT_ID)).thenReturn(true); + SsoAdminService emptyService = emptyService(); + + assertError("AccessDeniedException", + () -> emptyService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1")); } @Test @@ -276,6 +350,22 @@ void clearRemovesPersistedServiceState() { () -> service.getAssignmentOperation(service.getInstanceArn(), operation.requestId())); } + private SsoAdminService emptyService() { + return new SsoAdminService( + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + organizationsService, + "999999999999", + "us-east-1"); + } + private SsoApplication createApplication(String name, String clientToken) { ObjectNode request = mapper.createObjectNode(); request.put("InstanceArn", service.getInstanceArn()); From ed010cf39f20ccf22c92a8572da2611e0c499268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 08:47:10 -0400 Subject: [PATCH 06/99] feat(ssoadmin): create ABAC configuration --- .../test/SsoAdminAccountAssignmentTest.java | 25 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 65 +++++++++++++++++++ .../model/AccessControlAttribute.java | 7 ++ ...ceAccessControlAttributeConfiguration.java | 17 +++++ .../ssoadmin/SsoAdminIntegrationTest.java | 18 +++++ .../ssoadmin/SsoAdminServiceTest.java | 58 +++++++++++++++++ 8 files changed, 197 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AccessControlAttribute.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceAccessControlAttributeConfiguration.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 2d48a5e99e..01105385c4 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -130,6 +130,31 @@ void createApplicationAssignmentUsesAwsSdk() { } } + @Test + @DisplayName("creates an instance ABAC configuration through the AWS SDK") + void createInstanceAccessControlAttributeConfigurationUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center fixture"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + var response = sso.createInstanceAccessControlAttributeConfiguration(request -> request + .instanceArn(instanceArn) + .instanceAccessControlAttributeConfiguration(configuration -> configuration + .accessControlAttributes(attribute -> attribute + .key("Department") + .value(value -> value.source("${path:enterprise.department}"))))); + + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.createInstanceAccessControlAttributeConfiguration(request -> request + .instanceArn(instanceArn) + .instanceAccessControlAttributeConfiguration(configuration -> configuration + .accessControlAttributes(attribute -> attribute + .key("Department") + .value(value -> value.source("${path:enterprise.department}")))))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + } + } + @Test @DisplayName("creates an account instance through the AWS SDK") void createInstanceUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 0f63d2e8ac..eb0314c182 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -12,6 +12,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | +| `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index f0c3cebca4..c3e3c3e51c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -28,6 +28,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, return switch (action) { case "ListInstances" -> listInstances(callerAccountId); case "CreateInstance" -> createInstance(request, callerAccountId, region); + case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -82,6 +83,11 @@ private Response createInstance(JsonNode request, String callerAccountId, String return Response.ok(mapper.createObjectNode().put("InstanceArn", instance.instanceArn())).build(); } + private Response createInstanceAccessControlAttributeConfiguration(JsonNode request) { + service.createInstanceAccessControlAttributeConfiguration(request); + return Response.ok().build(); + } + private Response addRegion(JsonNode request) { var region = service.addRegion(request); return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 277f3408fe..c235e07d05 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -14,7 +14,9 @@ import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; +import io.github.hectorvent.floci.services.ssoadmin.model.AccessControlAttribute; import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; +import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; @@ -38,6 +40,7 @@ public class SsoAdminService implements Resettable { private static final String INSTANCE_ARN = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; private static final String IDENTITY_STORE_ID = "d-9067f2a3c1"; private static final String PRIMARY_REGION = "us-east-1"; + private static final Pattern INSTANCE_ARN_PATTERN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso:::instance/(?:sso)?ins-[a-zA-Z0-9-.]{16}"); private static final Pattern PERMISSION_SET_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final Pattern PERMISSION_SET_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso:::permissionSet/(?:sso)?ins-[a-zA-Z0-9-.]{16}/ps-[a-zA-Z0-9-./]{16}"); private static final Pattern PRINCIPAL_ID = Pattern.compile("([0-9a-f]{10}-|)[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"); @@ -51,6 +54,8 @@ public class SsoAdminService implements Resettable { private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); private static final Pattern INSTANCE_NAME = Pattern.compile("[\\w+=,.@-]+"); + private static final Pattern ACCESS_CONTROL_ATTRIBUTE_KEY = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+"); + private static final Pattern ACCESS_CONTROL_ATTRIBUTE_SOURCE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@\\[\\]\\{\\}$\\\\\"]*"); private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; @@ -70,6 +75,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationAssignments; private final StorageBackend instances; private final StorageBackend instanceClientTokens; + private final StorageBackend accessControlAttributeConfigurations; private final OrganizationsService organizationsService; private final String defaultAccountId; private final String defaultRegion; @@ -87,6 +93,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), organizationsService, config.defaultAccountId(), config.defaultRegion()); @@ -101,6 +108,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend applicationAssignments, StorageBackend instances, StorageBackend instanceClientTokens, + StorageBackend accessControlAttributeConfigurations, OrganizationsService organizationsService, String defaultAccountId, String defaultRegion) { @@ -113,6 +121,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.applicationAssignments = applicationAssignments; this.instances = instances; this.instanceClientTokens = instanceClientTokens; + this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; this.organizationsService = organizationsService; this.defaultAccountId = defaultAccountId; this.defaultRegion = defaultRegion; @@ -155,6 +164,58 @@ public double regionAddedDateEpochSeconds(RegionMetadata region) { return java.time.Instant.parse(region.addedDate()).toEpochMilli() / 1000.0d; } + public synchronized InstanceAccessControlAttributeConfiguration createInstanceAccessControlAttributeConfiguration(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + requireInstance(instanceArn); + if (accessControlAttributeConfigurations.get(instanceArn).isPresent()) { + throw conflict("An instance access control attribute configuration already exists for this IAM Identity Center instance."); + } + JsonNode configurationNode = request == null ? null : request.get("InstanceAccessControlAttributeConfiguration"); + if (configurationNode == null || !configurationNode.isObject()) { + throw validation("InstanceAccessControlAttributeConfiguration must be an object."); + } + JsonNode attributesNode = configurationNode.get("AccessControlAttributes"); + if (attributesNode == null || !attributesNode.isArray()) { + throw validation("AccessControlAttributes must be an array."); + } + if (attributesNode.size() > 50) { + throw validation("AccessControlAttributes can contain at most 50 attributes."); + } + List attributes = new ArrayList<>(); + for (JsonNode attributeNode : attributesNode) { + if (!attributeNode.isObject()) { + throw validation("Each access control attribute must be an object."); + } + String key = required(attributeNode, "Key"); + if (key.length() > 128 || !ACCESS_CONTROL_ATTRIBUTE_KEY.matcher(key).matches()) { + throw validation("Access control attribute Key is invalid."); + } + JsonNode valueNode = attributeNode.get("Value"); + if (valueNode == null || !valueNode.isObject()) { + throw validation("Access control attribute Value must be an object."); + } + JsonNode sourceNode = valueNode.get("Source"); + if (sourceNode == null || !sourceNode.isArray() || sourceNode.size() != 1 || !sourceNode.get(0).isTextual()) { + throw validation("Access control attribute Source must contain exactly one string."); + } + String source = sourceNode.get(0).textValue(); + if (source.length() > 256 || !ACCESS_CONTROL_ATTRIBUTE_SOURCE.matcher(source).matches()) { + throw validation("Access control attribute Source is invalid."); + } + attributes.add(new AccessControlAttribute(key, source)); + } + InstanceAccessControlAttributeConfiguration configuration = new InstanceAccessControlAttributeConfiguration( + instanceArn, attributes, "ENABLED", null); + accessControlAttributeConfigurations.put(instanceArn, configuration); + return configuration; + } + + public InstanceAccessControlAttributeConfiguration getInstanceAccessControlAttributeConfiguration(String instanceArn) { + requireInstance(instanceArn); + return accessControlAttributeConfigurations.get(instanceArn) + .orElseThrow(() -> notFound("Instance access control attribute configuration not found for: " + instanceArn)); + } + public synchronized SsoInstance createInstance(JsonNode request, String callerAccountId, String region) { validateAccountId(callerAccountId); validateRegionName(region); @@ -698,6 +759,9 @@ private static String valueOr(JsonNode request, String field, String fallback) { private static AwsException quota(String message) { return new AwsException("ServiceQuotaExceededException", message, 400); } private static AwsException accessDenied(String message) { return new AwsException("AccessDeniedException", message, 400); } private SsoInstance requireInstance(String arn) { + if (arn == null || arn.length() < 10 || arn.length() > 1224 || !INSTANCE_ARN_PATTERN.matcher(arn).matches()) { + throw validation("InstanceArn is invalid."); + } SsoInstance instance = findInstanceByArn(arn); if (instance == null) { throw notFound("IAM Identity Center instance not found: " + arn); @@ -726,6 +790,7 @@ public void clear() { applicationAssignments.clear(); instances.clear(); instanceClientTokens.clear(); + accessControlAttributeConfigurations.clear(); ensureBootstrapInstance(defaultAccountId, defaultRegion); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AccessControlAttribute.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AccessControlAttribute.java new file mode 100644 index 0000000000..1e828d485d --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AccessControlAttribute.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record AccessControlAttribute(String key, String source) { +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceAccessControlAttributeConfiguration.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceAccessControlAttributeConfiguration.java new file mode 100644 index 0000000000..65d05512e7 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceAccessControlAttributeConfiguration.java @@ -0,0 +1,17 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.ArrayList; +import java.util.List; + +@RegisterForReflection +public record InstanceAccessControlAttributeConfiguration( + String instanceArn, + List accessControlAttributes, + String status, + String statusReason) { + public InstanceAccessControlAttributeConfiguration { + accessControlAttributes = accessControlAttributes == null ? new ArrayList<>() : accessControlAttributes; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index c08f728744..df2b7793be 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -160,6 +160,24 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void createInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() { + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"InstanceAccessControlAttributeConfiguration\":{\"AccessControlAttributes\":[{" + + "\"Key\":\"Department\",\"Value\":{\"Source\":[\"${path:enterprise.department}\"]}}]}}"; + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateInstanceAccessControlAttributeConfiguration") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + } + @Test void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { String auth = "AWS4-HMAC-SHA256 Credential=222233334444/20260101/us-west-2/sso/aws4_request"; diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 722ff85e79..f127477256 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; +import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; @@ -42,12 +43,68 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), organizationsService, ACCOUNT_ID, "us-east-1"); service.ensureBootstrapInstance(ACCOUNT_ID, "us-east-1"); } + @Test + void createInstanceAccessControlAttributeConfigurationPersistsAndValidatesAwsShape() { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes") + .addObject() + .put("Key", "Department") + .putObject("Value") + .putArray("Source") + .add("${path:enterprise.department}"); + + InstanceAccessControlAttributeConfiguration created = + service.createInstanceAccessControlAttributeConfiguration(request); + assertEquals("ENABLED", created.status()); + assertEquals(1, created.accessControlAttributes().size()); + assertEquals("Department", created.accessControlAttributes().get(0).key()); + assertEquals("${path:enterprise.department}", created.accessControlAttributes().get(0).source()); + assertEquals(created, service.getInstanceAccessControlAttributeConfiguration(service.getInstanceArn())); + assertError("ConflictException", () -> service.createInstanceAccessControlAttributeConfiguration(request)); + } + + @Test + void createInstanceAccessControlAttributeConfigurationRejectsInvalidAttributes() { + ObjectNode malformedInstanceArn = mapper.createObjectNode(); + malformedInstanceArn.put("InstanceArn", "not-an-arn"); + malformedInstanceArn.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes"); + assertError("ValidationException", + () -> service.createInstanceAccessControlAttributeConfiguration(malformedInstanceArn)); + + ObjectNode missingConfiguration = mapper.createObjectNode(); + missingConfiguration.put("InstanceArn", service.getInstanceArn()); + assertError("ValidationException", + () -> service.createInstanceAccessControlAttributeConfiguration(missingConfiguration)); + + ObjectNode tooMany = mapper.createObjectNode(); + tooMany.put("InstanceArn", service.getInstanceArn()); + var attributes = tooMany.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes"); + for (int i = 0; i < 51; i++) { + attributes.addObject().put("Key", "Key" + i).putObject("Value").putArray("Source").add("value"); + } + assertError("ValidationException", () -> service.createInstanceAccessControlAttributeConfiguration(tooMany)); + + ObjectNode invalidSourceCount = mapper.createObjectNode(); + invalidSourceCount.put("InstanceArn", service.getInstanceArn()); + invalidSourceCount.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes") + .addObject().put("Key", "Department").putObject("Value").putArray("Source") + .add("one").add("two"); + assertError("ValidationException", + () -> service.createInstanceAccessControlAttributeConfiguration(invalidSourceCount)); + } + @Test void createInstancePersistsMetadataAndSupportsIdempotentReplay() { SsoAdminService emptyService = emptyService(); @@ -361,6 +418,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), organizationsService, "999999999999", "us-east-1"); From 846cdaa3062a4e50dd7e21bc0d858503d481f43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 08:52:51 -0400 Subject: [PATCH 07/99] feat(ssoadmin): create trusted token issuers --- .../test/SsoAdminAccountAssignmentTest.java | 36 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 7 ++ .../services/ssoadmin/SsoAdminService.java | 113 ++++++++++++++++++ .../model/OidcJwtIssuerConfiguration.java | 11 ++ .../ssoadmin/model/TrustedTokenIssuer.java | 19 +++ .../ssoadmin/SsoAdminIntegrationTest.java | 32 +++++ .../ssoadmin/SsoAdminServiceTest.java | 58 +++++++++ 8 files changed, 277 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/OidcJwtIssuerConfiguration.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/TrustedTokenIssuer.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 01105385c4..655fb6ef8b 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -130,6 +130,42 @@ void createApplicationAssignmentUsesAwsSdk() { } } + @Test + @DisplayName("creates trusted token issuers through the AWS SDK") + void createTrustedTokenIssuerUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center fixture"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + var created = sso.createTrustedTokenIssuer(request -> request + .instanceArn(instanceArn) + .name("SdkIssuer") + .clientToken("sdk-tti-token") + .trustedTokenIssuerType("OIDC_JWT") + .trustedTokenIssuerConfiguration(configuration -> configuration + .oidcJwtConfiguration(oidc -> oidc + .claimAttributePath("sub") + .identityStoreAttributePath("userName") + .issuerUrl("https://issuer.example.com") + .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); + + assertThat(created.trustedTokenIssuerArn()).matches( + "arn:aws:sso::000000000000:trustedTokenIssuer/ssoins-[0-9a-f]{16}/tti-[0-9a-f-]{36}"); + var replay = sso.createTrustedTokenIssuer(request -> request + .instanceArn(instanceArn) + .name("SdkIssuer") + .clientToken("sdk-tti-token") + .trustedTokenIssuerType("OIDC_JWT") + .trustedTokenIssuerConfiguration(configuration -> configuration + .oidcJwtConfiguration(oidc -> oidc + .claimAttributePath("sub") + .identityStoreAttributePath("userName") + .issuerUrl("https://issuer.example.com") + .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); + assertThat(replay.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); + } + } + @Test @DisplayName("creates an instance ABAC configuration through the AWS SDK") void createInstanceAccessControlAttributeConfigurationUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index eb0314c182..e57aa4ca54 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -13,6 +13,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListInstances` | Lists the local IAM Identity Center instance. | | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | +| `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index c3e3c3e51c..ade5aa2e50 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -29,6 +29,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListInstances" -> listInstances(callerAccountId); case "CreateInstance" -> createInstance(request, callerAccountId, region); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); + case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -88,6 +89,12 @@ private Response createInstanceAccessControlAttributeConfiguration(JsonNode requ return Response.ok().build(); } + private Response createTrustedTokenIssuer(JsonNode request, String callerAccountId) { + var issuer = service.createTrustedTokenIssuer(request, callerAccountId); + return Response.ok(mapper.createObjectNode() + .put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn())).build(); + } + private Response addRegion(JsonNode request) { var region = service.addRegion(request); return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index c235e07d05..11bf939ecc 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -17,10 +17,12 @@ import io.github.hectorvent.floci.services.ssoadmin.model.AccessControlAttribute; import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; +import io.github.hectorvent.floci.services.ssoadmin.model.OidcJwtIssuerConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; +import io.github.hectorvent.floci.services.ssoadmin.model.TrustedTokenIssuer; import io.github.hectorvent.floci.services.organizations.OrganizationsService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -56,12 +58,16 @@ public class SsoAdminService implements Resettable { private static final Pattern INSTANCE_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final Pattern ACCESS_CONTROL_ATTRIBUTE_KEY = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+"); private static final Pattern ACCESS_CONTROL_ATTRIBUTE_SOURCE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@\\[\\]\\{\\}$\\\\\"]*"); + private static final Pattern OIDC_CLAIM_ATTRIBUTE_PATH = Pattern.compile("\\p{L}+(?:(\\.|_)\\p{L}+){0,2}"); + private static final Pattern OIDC_IDENTITY_STORE_ATTRIBUTE_PATH = Pattern.compile("\\p{L}+(?:\\.\\p{L}+){0,2}"); + private static final Pattern OIDC_ISSUER_URL = Pattern.compile("https?://[-a-zA-Z0-9+&@/%=~_|!:,.;]*[-a-zA-Z0-9+&@/%=~_|]"); private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; private static final int MANAGED_POLICY_QUOTA = 25; private static final int APPLICATION_QUOTA = 7000; private static final int APPLICATION_GROUP_ASSIGNMENT_QUOTA = 100; + private static final int TRUSTED_TOKEN_ISSUER_QUOTA = 10; private static final int MAX_INLINE_POLICY_BYTES = 32_768; private static final int MAX_INLINE_NON_WHITESPACE = 10_240; private static final Set PRINCIPAL_TYPES = Set.of("USER", "GROUP"); @@ -76,6 +82,8 @@ public class SsoAdminService implements Resettable { private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend accessControlAttributeConfigurations; + private final StorageBackend trustedTokenIssuers; + private final StorageBackend trustedTokenIssuerClientTokens; private final OrganizationsService organizationsService; private final String defaultAccountId; private final String defaultRegion; @@ -94,6 +102,8 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuers.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuer-client-tokens.json", new TypeReference>() {}), organizationsService, config.defaultAccountId(), config.defaultRegion()); @@ -109,6 +119,8 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend accessControlAttributeConfigurations, + StorageBackend trustedTokenIssuers, + StorageBackend trustedTokenIssuerClientTokens, OrganizationsService organizationsService, String defaultAccountId, String defaultRegion) { @@ -122,6 +134,8 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; + this.trustedTokenIssuers = trustedTokenIssuers; + this.trustedTokenIssuerClientTokens = trustedTokenIssuerClientTokens; this.organizationsService = organizationsService; this.defaultAccountId = defaultAccountId; this.defaultRegion = defaultRegion; @@ -164,6 +178,103 @@ public double regionAddedDateEpochSeconds(RegionMetadata region) { return java.time.Instant.parse(region.addedDate()).toEpochMilli() / 1000.0d; } + public synchronized TrustedTokenIssuer createTrustedTokenIssuer(JsonNode request, String callerAccountId) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + validateAccountId(callerAccountId); + String name = required(request, "Name"); + if (name.length() > 255 || !INSTANCE_NAME.matcher(name).matches()) { + throw validation("Name must be 1-255 characters and match [\\w+=,.@-]+."); + } + String issuerType = required(request, "TrustedTokenIssuerType"); + if (!"OIDC_JWT".equals(issuerType)) { + throw validation("TrustedTokenIssuerType must be OIDC_JWT."); + } + JsonNode configurationNode = request.get("TrustedTokenIssuerConfiguration"); + if (configurationNode == null || !configurationNode.isObject() + || configurationNode.size() != 1 + || !configurationNode.has("OidcJwtConfiguration") + || !configurationNode.get("OidcJwtConfiguration").isObject()) { + throw validation("TrustedTokenIssuerConfiguration must contain exactly one OidcJwtConfiguration."); + } + JsonNode oidcNode = configurationNode.get("OidcJwtConfiguration"); + String claimAttributePath = required(oidcNode, "ClaimAttributePath"); + if (claimAttributePath.length() > 255 || !OIDC_CLAIM_ATTRIBUTE_PATH.matcher(claimAttributePath).matches()) { + throw validation("ClaimAttributePath is invalid."); + } + String identityStoreAttributePath = required(oidcNode, "IdentityStoreAttributePath"); + if (identityStoreAttributePath.length() > 255 + || !OIDC_IDENTITY_STORE_ATTRIBUTE_PATH.matcher(identityStoreAttributePath).matches()) { + throw validation("IdentityStoreAttributePath is invalid."); + } + String issuerUrl = required(oidcNode, "IssuerUrl"); + if (issuerUrl.length() > 512 || !OIDC_ISSUER_URL.matcher(issuerUrl).matches()) { + throw validation("IssuerUrl is invalid."); + } + String jwksRetrievalOption = required(oidcNode, "JwksRetrievalOption"); + if (!"OPEN_ID_DISCOVERY".equals(jwksRetrievalOption)) { + throw validation("JwksRetrievalOption must be OPEN_ID_DISCOVERY."); + } + OidcJwtIssuerConfiguration oidcConfiguration = new OidcJwtIssuerConfiguration( + claimAttributePath, identityStoreAttributePath, issuerUrl, jwksRetrievalOption); + Map tags = parseTags(request.get("Tags")); + + JsonNode clientTokenNode = request.get("ClientToken"); + String clientToken = null; + if (clientTokenNode != null && !clientTokenNode.isNull()) { + if (!clientTokenNode.isTextual()) { + throw validation("ClientToken must be a string."); + } + clientToken = clientTokenNode.textValue(); + if (clientToken.isEmpty() || clientToken.length() > 64 || !CLIENT_TOKEN.matcher(clientToken).matches()) { + throw validation("ClientToken must be between 1 and 64 visible ASCII characters."); + } + } + String tokenKey = clientToken == null ? null : instanceArn + "::" + clientToken; + if (tokenKey != null) { + var priorArn = trustedTokenIssuerClientTokens.get(tokenKey); + if (priorArn.isPresent()) { + TrustedTokenIssuer prior = trustedTokenIssuers.get(priorArn.get()).orElse(null); + if (prior != null && trustedTokenIssuerMatches(prior, name, issuerType, oidcConfiguration, tags)) { + return prior; + } + throw new AwsException("IdempotentParameterMismatch", + "ClientToken was reused with different request parameters.", 400); + } + } + long count = trustedTokenIssuers.scan(ignored -> true).stream() + .filter(issuer -> instanceArn.equals(issuer.instanceArn())) + .count(); + if (count >= TRUSTED_TOKEN_ISSUER_QUOTA) { + throw quota("An IAM Identity Center instance can have at most 10 trusted token issuers."); + } + String instanceId = instanceArn.substring(instanceArn.lastIndexOf('/') + 1); + String ownerAccountId = instance.ownerAccountId() == null ? callerAccountId : instance.ownerAccountId(); + String issuerArn = "arn:aws:sso::" + ownerAccountId + ":trustedTokenIssuer/" + + instanceId + "/tti-" + UUID.randomUUID(); + TrustedTokenIssuer issuer = new TrustedTokenIssuer( + issuerArn, instanceArn, name, issuerType, oidcConfiguration, tags); + trustedTokenIssuers.put(issuerArn, issuer); + if (tokenKey != null) { + trustedTokenIssuerClientTokens.put(tokenKey, issuerArn); + } + return issuer; + } + + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { + return trustedTokenIssuers.get(trustedTokenIssuerArn) + .orElseThrow(() -> notFound("Trusted token issuer not found: " + trustedTokenIssuerArn)); + } + + private static boolean trustedTokenIssuerMatches(TrustedTokenIssuer issuer, String name, String issuerType, + OidcJwtIssuerConfiguration oidcConfiguration, + Map tags) { + return java.util.Objects.equals(issuer.name(), name) + && java.util.Objects.equals(issuer.trustedTokenIssuerType(), issuerType) + && java.util.Objects.equals(issuer.oidcJwtConfiguration(), oidcConfiguration) + && java.util.Objects.equals(issuer.tags(), tags); + } + public synchronized InstanceAccessControlAttributeConfiguration createInstanceAccessControlAttributeConfiguration(JsonNode request) { String instanceArn = required(request, "InstanceArn"); requireInstance(instanceArn); @@ -791,6 +902,8 @@ public void clear() { instances.clear(); instanceClientTokens.clear(); accessControlAttributeConfigurations.clear(); + trustedTokenIssuers.clear(); + trustedTokenIssuerClientTokens.clear(); ensureBootstrapInstance(defaultAccountId, defaultRegion); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/OidcJwtIssuerConfiguration.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/OidcJwtIssuerConfiguration.java new file mode 100644 index 0000000000..b48445aa61 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/OidcJwtIssuerConfiguration.java @@ -0,0 +1,11 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record OidcJwtIssuerConfiguration( + String claimAttributePath, + String identityStoreAttributePath, + String issuerUrl, + String jwksRetrievalOption) { +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/TrustedTokenIssuer.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/TrustedTokenIssuer.java new file mode 100644 index 0000000000..26d2b0f74c --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/TrustedTokenIssuer.java @@ -0,0 +1,19 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RegisterForReflection +public record TrustedTokenIssuer( + String trustedTokenIssuerArn, + String instanceArn, + String name, + String trustedTokenIssuerType, + OidcJwtIssuerConfiguration oidcJwtConfiguration, + Map tags) { + public TrustedTokenIssuer { + tags = tags == null ? new LinkedHashMap<>() : tags; + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index df2b7793be..1f48eb1d78 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -160,6 +160,38 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"Name\":\"IntegrationIssuer\",\"ClientToken\":\"tti-integration-token\"," + + "\"TrustedTokenIssuerType\":\"OIDC_JWT\",\"TrustedTokenIssuerConfiguration\":{" + + "\"OidcJwtConfiguration\":{\"ClaimAttributePath\":\"sub\"," + + "\"IdentityStoreAttributePath\":\"userName\",\"IssuerUrl\":\"https://issuer.example.com\"," + + "\"JwksRetrievalOption\":\"OPEN_ID_DISCOVERY\"}}}"; + + String arn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateTrustedTokenIssuer") + .body(request) + .when().post("/") + .then() + .statusCode(200) + .body("TrustedTokenIssuerArn", matchesPattern( + "arn:aws:sso::000000000000:trustedTokenIssuer/ssoins-[0-9a-f]{16}/tti-[0-9a-f-]{36}")) + .extract().path("TrustedTokenIssuerArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateTrustedTokenIssuer") + .body(request) + .when().post("/") + .then() + .statusCode(200) + .body("TrustedTokenIssuerArn", equalTo(arn)); + } + @Test void createInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index f127477256..3ab8e7838c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -12,6 +12,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; +import io.github.hectorvent.floci.services.ssoadmin.model.TrustedTokenIssuer; import io.github.hectorvent.floci.services.organizations.OrganizationsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,12 +45,50 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), organizationsService, ACCOUNT_ID, "us-east-1"); service.ensureBootstrapInstance(ACCOUNT_ID, "us-east-1"); } + @Test + void createTrustedTokenIssuerPersistsOidcConfigurationAndSupportsIdempotency() { + ObjectNode request = trustedTokenIssuerRequest("IssuerOne", "tti-token-one"); + + TrustedTokenIssuer created = service.createTrustedTokenIssuer(request, ACCOUNT_ID); + assertTrue(created.trustedTokenIssuerArn().matches( + "arn:aws:sso::123456789012:trustedTokenIssuer/ssoins-[0-9a-f]{16}/tti-[0-9a-f-]{36}")); + assertEquals("OIDC_JWT", created.trustedTokenIssuerType()); + assertEquals("https://issuer.example.com", created.oidcJwtConfiguration().issuerUrl()); + assertEquals(created, service.createTrustedTokenIssuer(request, ACCOUNT_ID)); + assertEquals(created, service.getTrustedTokenIssuer(created.trustedTokenIssuerArn())); + + ObjectNode mismatch = request.deepCopy(); + mismatch.put("Name", "IssuerTwo"); + assertError("IdempotentParameterMismatch", + () -> service.createTrustedTokenIssuer(mismatch, ACCOUNT_ID)); + } + + @Test + void createTrustedTokenIssuerValidatesUnionOidcFieldsAndQuota() { + ObjectNode invalidType = trustedTokenIssuerRequest("InvalidType", null); + invalidType.put("TrustedTokenIssuerType", "SAML"); + assertError("ValidationException", () -> service.createTrustedTokenIssuer(invalidType, ACCOUNT_ID)); + + ObjectNode invalidIssuerUrl = trustedTokenIssuerRequest("InvalidUrl", null); + invalidIssuerUrl.withObject("TrustedTokenIssuerConfiguration") + .withObject("OidcJwtConfiguration").put("IssuerUrl", "ftp://issuer.example.com"); + assertError("ValidationException", () -> service.createTrustedTokenIssuer(invalidIssuerUrl, ACCOUNT_ID)); + + for (int i = 0; i < 10; i++) { + service.createTrustedTokenIssuer(trustedTokenIssuerRequest("Issuer" + i, null), ACCOUNT_ID); + } + assertError("ServiceQuotaExceededException", + () -> service.createTrustedTokenIssuer(trustedTokenIssuerRequest("IssuerOverQuota", null), ACCOUNT_ID)); + } + @Test void createInstanceAccessControlAttributeConfigurationPersistsAndValidatesAwsShape() { ObjectNode request = mapper.createObjectNode(); @@ -419,11 +458,30 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), organizationsService, "999999999999", "us-east-1"); } + private ObjectNode trustedTokenIssuerRequest(String name, String clientToken) { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("Name", name); + request.put("TrustedTokenIssuerType", "OIDC_JWT"); + if (clientToken != null) { + request.put("ClientToken", clientToken); + } + request.putObject("TrustedTokenIssuerConfiguration") + .putObject("OidcJwtConfiguration") + .put("ClaimAttributePath", "sub") + .put("IdentityStoreAttributePath", "userName") + .put("IssuerUrl", "https://issuer.example.com") + .put("JwksRetrievalOption", "OPEN_ID_DISCOVERY"); + return request; + } + private SsoApplication createApplication(String name, String clientToken) { ObjectNode request = mapper.createObjectNode(); request.put("InstanceArn", service.getInstanceArn()); From 0dd44b44db5b4b592b0c0a36adb927f76d0a93e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 08:57:21 -0400 Subject: [PATCH 08/99] feat(ssoadmin): delete account assignments --- .../test/SsoAdminAccountAssignmentTest.java | 37 ++++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 ++++++++ .../services/ssoadmin/SsoAdminService.java | 43 +++++++++++++++++++ .../model/AssignmentDeletionOperation.java | 15 +++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 30 +++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 32 ++++++++++++++ 7 files changed, 177 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentDeletionOperation.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 655fb6ef8b..7ec00424d0 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -226,6 +226,43 @@ void createInstanceQuotaUsesAwsSdk() { } } + @Test + @DisplayName("deletes account assignments through the AWS SDK") + void deleteAccountAssignmentUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only account and principal identifiers"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("FlociDeleteAssignmentAdmins")) + .permissionSet().permissionSetArn(); + sso.createAccountAssignment(request -> request + .instanceArn(instanceArn) + .targetId("123456789012") + .targetType(TargetType.AWS_ACCOUNT) + .permissionSetArn(permissionSetArn) + .principalType(PrincipalType.GROUP) + .principalId("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); + + var deleted = sso.deleteAccountAssignment(request -> request + .instanceArn(instanceArn) + .targetId("123456789012") + .targetType(TargetType.AWS_ACCOUNT) + .permissionSetArn(permissionSetArn) + .principalType(PrincipalType.GROUP) + .principalId("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); + + assertThat(deleted.accountAssignmentDeletionStatus()).isNotNull(); + assertThat(deleted.accountAssignmentDeletionStatus().statusAsString()).isEqualTo("SUCCEEDED"); + assertThat(deleted.accountAssignmentDeletionStatus().requestId()).isNotBlank(); + assertThat(sso.listAccountAssignments(request -> request + .instanceArn(instanceArn) + .accountId("123456789012") + .permissionSetArn(permissionSetArn)).accountAssignments()).isEmpty(); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index e57aa4ca54..620b7196db 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -29,6 +29,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | +| `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index ade5aa2e50..e4983dfb6d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -45,6 +45,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); case "ListAccountAssignments" -> listAccountAssignments(request); case "CreateAccountAssignment" -> createAccountAssignment(request); + case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); default -> throw new AwsException("UnknownOperationException", "Operation " + action + " is not supported.", 400); }; @@ -206,6 +207,24 @@ private Response createAccountAssignment(JsonNode request) { return Response.ok(response).build(); } + private Response deleteAccountAssignment(JsonNode request) { + var operation = service.deleteAssignment(request); + ObjectNode response = mapper.createObjectNode(); + ObjectNode status = response.putObject("AccountAssignmentDeletionStatus"); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + status.put("TargetId", operation.accountId()); + status.put("TargetType", "AWS_ACCOUNT"); + status.put("PermissionSetArn", operation.permissionSetArn()); + status.put("PrincipalId", operation.principalId()); + status.put("PrincipalType", operation.principalType()); + if (operation.failureReason() != null) { + status.put("FailureReason", operation.failureReason()); + } + return Response.ok(response).build(); + } + private Response describeAssignment(JsonNode request) { AssignmentOperation op = service.getAssignmentOperation( SsoAdminService.required(request, "InstanceArn"), diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 11bf939ecc..c81cfff0e9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -11,6 +11,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; @@ -75,6 +76,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend permissionSets; private final StorageBackend assignments; private final StorageBackend assignmentOperations; + private final StorageBackend assignmentDeletionOperations; private final StorageBackend regions; private final StorageBackend applications; private final StorageBackend applicationClientTokens; @@ -95,6 +97,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-permission-sets.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-assignment-deletion-operations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), @@ -112,6 +115,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ SsoAdminService(StorageBackend permissionSets, StorageBackend assignments, StorageBackend assignmentOperations, + StorageBackend assignmentDeletionOperations, StorageBackend regions, StorageBackend applications, StorageBackend applicationClientTokens, @@ -127,6 +131,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; + this.assignmentDeletionOperations = assignmentDeletionOperations; this.regions = regions; this.applications = applications; this.applicationClientTokens = applicationClientTokens; @@ -634,6 +639,43 @@ public synchronized AssignmentOperation createAssignment(JsonNode request) { return operation; } + public synchronized AssignmentDeletionOperation deleteAssignment(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + requireInstance(instanceArn); + String account = validateAccountId(required(request, "TargetId")); + if (!"AWS_ACCOUNT".equals(required(request, "TargetType"))) { + throw validation("TargetType must be AWS_ACCOUNT."); + } + String permission = required(request, "PermissionSetArn"); + getPermissionSet(instanceArn, permission); + String principal = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + String key = account + "::" + permission + "::" + principal; + Assignment existing = assignments.get(key) + .orElseThrow(() -> notFound("Account assignment not found.")); + if (!principalType.equals(existing.principalType())) { + throw notFound("Account assignment not found."); + } + assignments.delete(key); + String requestId = UUID.randomUUID().toString(); + AssignmentDeletionOperation operation = new AssignmentDeletionOperation( + requestId, "SUCCEEDED", System.currentTimeMillis(), account, permission, principal, principalType, null); + assignmentDeletionOperations.put(requestId, operation); + return operation; + } + + public AssignmentDeletionOperation getAssignmentDeletionOperation(String instanceArn, String requestId) { + requireInstance(instanceArn); + if (requestId == null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { + throw validation("AccountAssignmentDeletionRequestId must be a UUID."); + } + return assignmentDeletionOperations.get(requestId) + .orElseThrow(() -> notFound("Assignment deletion operation not found: " + requestId)); + } + public AssignmentOperation getAssignmentOperation(String instanceArn, String requestId) { requireInstance(instanceArn); if (requestId == null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { @@ -895,6 +937,7 @@ public void clear() { permissionSets.clear(); assignments.clear(); assignmentOperations.clear(); + assignmentDeletionOperations.clear(); regions.clear(); applications.clear(); applicationClientTokens.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentDeletionOperation.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentDeletionOperation.java new file mode 100644 index 0000000000..43be98c437 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentDeletionOperation.java @@ -0,0 +1,15 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record AssignmentDeletionOperation( + String requestId, + String status, + long createdDateEpochMillis, + String accountId, + String permissionSetArn, + String principalId, + String principalType, + String failureReason) { +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 1f48eb1d78..fd8732c97d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -260,6 +260,36 @@ void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { .body("Instances[0].Regions[0].Status", equalTo("ACTIVE")); } + @Test + void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { + String instanceArn = listInstancesArn(); + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"DeleteAssignmentIntegration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + String assignmentRequest = "{\"InstanceArn\":\"" + instanceArn + "\",\"TargetId\":\"123456789012\"," + + "\"TargetType\":\"AWS_ACCOUNT\",\"PermissionSetArn\":\"" + permissionSetArn + "\"," + + "\"PrincipalType\":\"GROUP\",\"PrincipalId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"}"; + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateAccountAssignment") + .body(assignmentRequest).when().post("/").then().statusCode(200); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteAccountAssignment") + .body(assignmentRequest) + .when().post("/") + .then() + .statusCode(200) + .body("AccountAssignmentDeletionStatus.Status", equalTo("SUCCEEDED")) + .body("AccountAssignmentDeletionStatus.RequestId", matchesPattern("[0-9a-f-]{36}")) + .body("AccountAssignmentDeletionStatus.CreatedDate", org.hamcrest.Matchers.greaterThan(0.0f)); + } + @Test void unknownAction_returnsUnknownOperationException() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 3ab8e7838c..b7178179b3 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -6,6 +6,7 @@ import io.github.hectorvent.floci.core.storage.InMemoryStorage; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; @@ -38,6 +39,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -432,6 +434,35 @@ void assignmentValidationAndDuplicateDetectionAreModeled() { assertError("ValidationException", () -> service.createAssignment(invalidPrincipalType)); } + @Test + void deleteAccountAssignmentRemovesAssignmentAndCreatesDeletionOperation() { + PermissionSet permissionSet = createPermissionSet("DeleteAssignmentAdmins"); + ObjectNode request = assignmentRequest(permissionSet.arn()); + service.createAssignment(request); + + AssignmentDeletionOperation deleted = service.deleteAssignment(request); + assertEquals("SUCCEEDED", deleted.status()); + assertTrue(deleted.createdDateEpochMillis() > 0); + assertTrue(service.listAssignments(service.getInstanceArn(), ACCOUNT_ID, permissionSet.arn()).isEmpty()); + assertEquals(deleted, service.getAssignmentDeletionOperation(service.getInstanceArn(), deleted.requestId())); + assertError("ResourceNotFoundException", () -> service.deleteAssignment(request)); + } + + @Test + void deleteAccountAssignmentValidatesPrincipalTypeAndTargetType() { + PermissionSet permissionSet = createPermissionSet("DeleteValidationAdmins"); + ObjectNode request = assignmentRequest(permissionSet.arn()); + service.createAssignment(request); + + ObjectNode invalidTargetType = request.deepCopy(); + invalidTargetType.put("TargetType", "APPLICATION"); + assertError("ValidationException", () -> service.deleteAssignment(invalidTargetType)); + + ObjectNode wrongPrincipalType = request.deepCopy(); + wrongPrincipalType.put("PrincipalType", "USER"); + assertError("ResourceNotFoundException", () -> service.deleteAssignment(wrongPrincipalType)); + } + @Test void clearRemovesPersistedServiceState() { PermissionSet permissionSet = createPermissionSet("ResetAdmins"); @@ -451,6 +482,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 52a8a15cd1bea70f482e41bf13edc09fb67b44e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:08:49 -0400 Subject: [PATCH 09/99] feat(ssoadmin): delete applications --- .../test/SsoAdminAccountAssignmentTest.java | 20 +++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 16 ++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 29 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 24 +++++++++++++++ 6 files changed, 96 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 7ec00424d0..6a2423983c 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -226,6 +226,26 @@ void createInstanceQuotaUsesAwsSdk() { } } + @Test + @DisplayName("deletes applications through the AWS SDK") + void deleteApplicationUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center fixture"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SdkDeleteApplication")) + .applicationArn(); + + var response = sso.deleteApplication(request -> request.applicationArn(applicationArn)); + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.deleteApplication(request -> request.applicationArn(applicationArn))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 620b7196db..281b5346c6 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -17,6 +17,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | +| `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index e4983dfb6d..f670eae498 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -33,6 +33,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); + case "DeleteApplication" -> deleteApplication(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -101,6 +102,11 @@ private Response addRegion(JsonNode request) { return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); } + private Response deleteApplication(JsonNode request) { + service.deleteApplication(SsoAdminService.required(request, "ApplicationArn")); + return Response.ok().build(); + } + private Response createApplicationAssignment(JsonNode request) { service.createApplicationAssignment(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index c81cfff0e9..ff51adafa1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -375,6 +375,22 @@ public synchronized SsoInstance createInstance(JsonNode request, String callerAc return instance; } + public synchronized void deleteApplication(String applicationArn) { + getApplication(applicationArn); + applications.delete(applicationArn); + for (String key : new java.util.ArrayList<>(applicationAssignments.keys())) { + ApplicationAssignment assignment = applicationAssignments.get(key).orElse(null); + if (assignment != null && applicationArn.equals(assignment.applicationArn())) { + applicationAssignments.delete(key); + } + } + for (String key : new java.util.ArrayList<>(applicationClientTokens.keys())) { + if (applicationArn.equals(applicationClientTokens.get(key).orElse(null))) { + applicationClientTokens.delete(key); + } + } + } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index fd8732c97d..feb4e13ba4 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -260,6 +260,35 @@ void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { .body("Instances[0].Regions[0].Status", equalTo("ACTIVE")); } + @Test + void deleteApplicationReturnsEmptyResponseAndRemovesApplication() { + String createRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"DeleteApplicationIntegration\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(createRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplication") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplication") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + } + @Test void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { String instanceArn = listInstancesArn(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index b7178179b3..2409b28091 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -210,6 +210,30 @@ void createInstanceRejectsOrganizationsManagementAccounts() { () -> emptyService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1")); } + @Test + void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { + ObjectNode createRequest = mapper.createObjectNode(); + createRequest.put("InstanceArn", service.getInstanceArn()); + createRequest.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + createRequest.put("Name", "DeleteMeApplication"); + createRequest.put("ClientToken", "delete-app-token"); + SsoApplication application = service.createApplication(createRequest, ACCOUNT_ID, "us-east-1"); + + ObjectNode assignment = mapper.createObjectNode(); + assignment.put("ApplicationArn", application.applicationArn()); + assignment.put("PrincipalId", PRINCIPAL_ID); + assignment.put("PrincipalType", "GROUP"); + service.createApplicationAssignment(assignment); + + service.deleteApplication(application.applicationArn()); + assertError("ResourceNotFoundException", () -> service.getApplication(application.applicationArn())); + assertError("ResourceNotFoundException", () -> service.createApplicationAssignment(assignment)); + + SsoApplication recreated = service.createApplication(createRequest, ACCOUNT_ID, "us-east-1"); + assertFalse(recreated.applicationArn().equals(application.applicationArn())); + assertError("ResourceNotFoundException", () -> service.deleteApplication(application.applicationArn())); + } + @Test void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { SsoApplication application = createApplication("Assignment App", "assignment-app-token"); From d9cfa927f1c8634ea06955ebc450ae60880bf0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:15:25 -0400 Subject: [PATCH 10/99] feat(ssoadmin): delete application access scopes --- .../test/SsoAdminAccountAssignmentTest.java | 24 ++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 31 +++++++++++++++++++ .../model/ApplicationAccessScope.java | 12 +++++++ .../ssoadmin/SsoAdminServiceTest.java | 25 +++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAccessScope.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 6a2423983c..a76ca399fb 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -226,6 +226,30 @@ void createInstanceQuotaUsesAwsSdk() { } } + @Test + @DisplayName("deletes application access scopes through the AWS SDK") + void deleteApplicationAccessScopeUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center fixture"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SdkDeleteApplicationScope")) + .applicationArn(); + + assertThatThrownBy(() -> sso.deleteApplicationAccessScope(request -> request + .applicationArn(applicationArn) + .scope("api:read"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> sso.deleteApplicationAccessScope(request -> request + .applicationArn(applicationArn) + .scope("bad scope"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ValidationException.class); + } + } + @Test @DisplayName("deletes applications through the AWS SDK") void deleteApplicationUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 281b5346c6..f6bd71fbbd 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -18,6 +18,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | +| `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index f670eae498..7afc1f3820 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -34,6 +34,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DeleteApplication" -> deleteApplication(request); + case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -107,6 +108,11 @@ private Response deleteApplication(JsonNode request) { return Response.ok().build(); } + private Response deleteApplicationAccessScope(JsonNode request) { + service.deleteApplicationAccessScope(request); + return Response.ok().build(); + } + private Response createApplicationAssignment(JsonNode request) { service.createApplicationAssignment(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ff51adafa1..035ca6e055 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -12,6 +12,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; @@ -53,6 +54,7 @@ public class SsoAdminService implements Resettable { private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); private static final Pattern APPLICATION_PROVIDER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::aws:applicationProvider/[a-zA-Z0-9-/]+"); private static final Pattern APPLICATION_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); + private static final Pattern APPLICATION_ACCESS_SCOPE = Pattern.compile("([A-Za-z0-9_]{1,50})(:[A-Za-z0-9_]{1,50}){0,1}(:[A-Za-z0-9_]{1,50}){0,1}"); private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); @@ -81,6 +83,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applications; private final StorageBackend applicationClientTokens; private final StorageBackend applicationAssignments; + private final StorageBackend applicationAccessScopes; private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend accessControlAttributeConfigurations; @@ -102,6 +105,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-access-scopes.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), @@ -120,6 +124,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend applications, StorageBackend applicationClientTokens, StorageBackend applicationAssignments, + StorageBackend applicationAccessScopes, StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend accessControlAttributeConfigurations, @@ -136,6 +141,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.applications = applications; this.applicationClientTokens = applicationClientTokens; this.applicationAssignments = applicationAssignments; + this.applicationAccessScopes = applicationAccessScopes; this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; @@ -389,6 +395,26 @@ public synchronized void deleteApplication(String applicationArn) { applicationClientTokens.delete(key); } } + for (String key : new java.util.ArrayList<>(applicationAccessScopes.keys())) { + ApplicationAccessScope scope = applicationAccessScopes.get(key).orElse(null); + if (scope != null && applicationArn.equals(scope.applicationArn())) { + applicationAccessScopes.delete(key); + } + } + } + + public synchronized void deleteApplicationAccessScope(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String scope = required(request, "Scope"); + if (!APPLICATION_ACCESS_SCOPE.matcher(scope).matches()) { + throw validation("Scope is invalid."); + } + String key = applicationAccessScopeKey(applicationArn, scope); + if (applicationAccessScopes.get(key).isEmpty()) { + throw notFound("Application access scope not found: " + scope); + } + applicationAccessScopes.delete(key); } public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { @@ -896,6 +922,10 @@ private static String customerManagedPolicyKey(String name, String path) { return name.toLowerCase(java.util.Locale.ROOT) + "\n" + path; } + static String applicationAccessScopeKey(String applicationArn, String scope) { + return applicationArn + "\n" + scope; + } + private static String validateRegionName(String value) { if (value == null || value.length() > 32 || !REGION_NAME.matcher(value).matches()) { throw validation("RegionName must be 1-32 characters and use an AWS Region name format."); @@ -958,6 +988,7 @@ public void clear() { applications.clear(); applicationClientTokens.clear(); applicationAssignments.clear(); + applicationAccessScopes.clear(); instances.clear(); instanceClientTokens.clear(); accessControlAttributeConfigurations.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAccessScope.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAccessScope.java new file mode 100644 index 0000000000..c3fd5fb403 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAccessScope.java @@ -0,0 +1,12 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; + +@RegisterForReflection +public record ApplicationAccessScope(String applicationArn, String scope, List authorizedTargets) { + public ApplicationAccessScope { + authorizedTargets = authorizedTargets == null ? List.of() : List.copyOf(authorizedTargets); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 2409b28091..3cc9d4ec09 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; @@ -31,10 +32,12 @@ class SsoAdminServiceTest { private final ObjectMapper mapper = new ObjectMapper(); private SsoAdminService service; private OrganizationsService organizationsService; + private InMemoryStorage applicationAccessScopes; @BeforeEach void setUp() { organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); + applicationAccessScopes = new InMemoryStorage<>(); service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), @@ -44,6 +47,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + applicationAccessScopes, new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -234,6 +238,26 @@ void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { assertError("ResourceNotFoundException", () -> service.deleteApplication(application.applicationArn())); } + @Test + void deleteApplicationAccessScopeDeletesStoredScopeAndValidatesRequest() { + SsoApplication application = createApplication("Scope App", "scope-app-token"); + String scope = "api:read"; + String key = SsoAdminService.applicationAccessScopeKey(application.applicationArn(), scope); + applicationAccessScopes.put(key, new ApplicationAccessScope( + application.applicationArn(), scope, java.util.List.of(service.getInstanceArn()))); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("Scope", scope); + service.deleteApplicationAccessScope(request); + assertTrue(applicationAccessScopes.get(key).isEmpty()); + assertError("ResourceNotFoundException", () -> service.deleteApplicationAccessScope(request)); + + ObjectNode invalid = request.deepCopy(); + invalid.put("Scope", "bad scope"); + assertError("ValidationException", () -> service.deleteApplicationAccessScope(invalid)); + } + @Test void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { SsoApplication application = createApplication("Assignment App", "assignment-app-token"); @@ -511,6 +535,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From d0c6e3ede1e01725efdc449976dd4d7043251195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:19:56 -0400 Subject: [PATCH 11/99] feat(ssoadmin): delete application assignments --- .../test/SsoAdminAccountAssignmentTest.java | 31 ++++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 15 ++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 37 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 21 +++++++++++ 6 files changed, 111 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index a76ca399fb..c402d9dbbd 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -130,6 +130,37 @@ void createApplicationAssignmentUsesAwsSdk() { } } + @Test + @DisplayName("deletes application assignments through the AWS SDK") + void deleteApplicationAssignmentUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SDK Delete Assignment App")) + .applicationArn(); + String principalId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + sso.createApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId(principalId) + .principalType("GROUP")); + + var deleted = sso.deleteApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId(principalId) + .principalType("GROUP")); + assertThat(deleted.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.deleteApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId(principalId) + .principalType("GROUP"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + } + } + @Test @DisplayName("creates trusted token issuers through the AWS SDK") void createTrustedTokenIssuerUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index f6bd71fbbd..cd76969e74 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -19,6 +19,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | +| `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 7afc1f3820..d580545107 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -35,6 +35,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); + case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -118,6 +119,11 @@ private Response createApplicationAssignment(JsonNode request) { return Response.ok().build(); } + private Response deleteApplicationAssignment(JsonNode request) { + service.deleteApplicationAssignment(request); + return Response.ok().build(); + } + private Response createApplication(JsonNode request, String callerAccountId, String region) { SsoApplication application = service.createApplication(request, callerAccountId, region); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 035ca6e055..d5d8ed9877 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -417,6 +417,21 @@ public synchronized void deleteApplicationAccessScope(JsonNode request) { applicationAccessScopes.delete(key); } + public synchronized void deleteApplicationAssignment(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String principalId = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + String key = applicationAssignmentKey(applicationArn, principalId, principalType); + if (applicationAssignments.get(key).isEmpty()) { + throw notFound("Application assignment not found for the specified principal."); + } + applicationAssignments.delete(key); + } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index feb4e13ba4..a139d34445 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -160,6 +160,43 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { + String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Delete Assignment Integration\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(appRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + String assignmentRequest = "{\"ApplicationArn\":\"" + applicationArn + "\"," + + "\"PrincipalId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"PrincipalType\":\"GROUP\"}"; + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplicationAssignment") + .body(assignmentRequest) + .when().post("/") + .then().statusCode(200); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationAssignment") + .body(assignmentRequest) + .when().post("/") + .then().statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationAssignment") + .body(assignmentRequest) + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + } + @Test void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 3cc9d4ec09..657b6a70c9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -282,6 +282,27 @@ void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { assertError("ResourceNotFoundException", () -> service.createApplicationAssignment(missingApplication)); } + @Test + void deleteApplicationAssignmentRevokesDirectAssignmentAndValidatesPrincipal() { + SsoApplication application = createApplication("Delete Assignment App", "delete-assignment-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("PrincipalId", PRINCIPAL_ID); + request.put("PrincipalType", "USER"); + service.createApplicationAssignment(request); + + service.deleteApplicationAssignment(request); + assertError("ResourceNotFoundException", () -> service.deleteApplicationAssignment(request)); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("PrincipalType", "ROLE"); + assertError("ValidationException", () -> service.deleteApplicationAssignment(invalidType)); + + ObjectNode invalidPrincipal = request.deepCopy(); + invalidPrincipal.put("PrincipalId", "not-a-guid"); + assertError("ValidationException", () -> service.deleteApplicationAssignment(invalidPrincipal)); + } + @Test void createApplicationAssignmentEnforcesTheDocumentedGroupQuota() { SsoApplication application = createApplication("Group Quota App", "group-quota-app-token"); From 22e992b7bbd379f5b1c29cc4a215c4049d27295f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:24:49 -0400 Subject: [PATCH 12/99] feat(ssoadmin): delete application authentication methods --- .../test/SsoAdminAccountAssignmentTest.java | 24 +++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 30 +++++++++++++++++++ .../ApplicationAuthenticationMethod.java | 10 +++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 29 ++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 26 ++++++++++++++++ 7 files changed, 126 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAuthenticationMethod.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c402d9dbbd..c8845e3203 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -161,6 +161,30 @@ void deleteApplicationAssignmentUsesAwsSdk() { } } + @Test + @DisplayName("deletes application authentication methods through the AWS SDK") + void deleteApplicationAuthenticationMethodUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SDK Delete Authentication Method")) + .applicationArn(); + + assertThatThrownBy(() -> sso.deleteApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> sso.deleteApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("SAML"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ValidationException.class); + } + } + @Test @DisplayName("creates trusted token issuers through the AWS SDK") void createTrustedTokenIssuerUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index cd76969e74..498e092851 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -20,6 +20,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | +| `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index d580545107..cdee9a1f6c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -36,6 +36,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); + case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -124,6 +125,11 @@ private Response deleteApplicationAssignment(JsonNode request) { return Response.ok().build(); } + private Response deleteApplicationAuthenticationMethod(JsonNode request) { + service.deleteApplicationAuthenticationMethod(request); + return Response.ok().build(); + } + private Response createApplication(JsonNode request, String callerAccountId, String region) { SsoApplication application = service.createApplication(request, callerAccountId, region); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index d5d8ed9877..6204cba474 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -14,6 +14,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAuthenticationMethod; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; import io.github.hectorvent.floci.services.ssoadmin.model.AccessControlAttribute; @@ -84,6 +85,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationClientTokens; private final StorageBackend applicationAssignments; private final StorageBackend applicationAccessScopes; + private final StorageBackend applicationAuthenticationMethods; private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend accessControlAttributeConfigurations; @@ -106,6 +108,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-access-scopes.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-authentication-methods.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), @@ -125,6 +128,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend applicationClientTokens, StorageBackend applicationAssignments, StorageBackend applicationAccessScopes, + StorageBackend applicationAuthenticationMethods, StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend accessControlAttributeConfigurations, @@ -142,6 +146,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.applicationClientTokens = applicationClientTokens; this.applicationAssignments = applicationAssignments; this.applicationAccessScopes = applicationAccessScopes; + this.applicationAuthenticationMethods = applicationAuthenticationMethods; this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; @@ -401,6 +406,12 @@ public synchronized void deleteApplication(String applicationArn) { applicationAccessScopes.delete(key); } } + for (String key : new java.util.ArrayList<>(applicationAuthenticationMethods.keys())) { + ApplicationAuthenticationMethod method = applicationAuthenticationMethods.get(key).orElse(null); + if (method != null && applicationArn.equals(method.applicationArn())) { + applicationAuthenticationMethods.delete(key); + } + } } public synchronized void deleteApplicationAccessScope(JsonNode request) { @@ -432,6 +443,20 @@ public synchronized void deleteApplicationAssignment(JsonNode request) { applicationAssignments.delete(key); } + public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String authenticationMethodType = required(request, "AuthenticationMethodType"); + if (!"IAM".equals(authenticationMethodType)) { + throw validation("AuthenticationMethodType must be IAM."); + } + String key = applicationAuthenticationMethodKey(applicationArn, authenticationMethodType); + if (applicationAuthenticationMethods.get(key).isEmpty()) { + throw notFound("Application authentication method not found: " + authenticationMethodType); + } + applicationAuthenticationMethods.delete(key); + } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); @@ -941,6 +966,10 @@ static String applicationAccessScopeKey(String applicationArn, String scope) { return applicationArn + "\n" + scope; } + static String applicationAuthenticationMethodKey(String applicationArn, String authenticationMethodType) { + return applicationArn + "\n" + authenticationMethodType; + } + private static String validateRegionName(String value) { if (value == null || value.length() > 32 || !REGION_NAME.matcher(value).matches()) { throw validation("RegionName must be 1-32 characters and use an AWS Region name format."); @@ -1004,6 +1033,7 @@ public void clear() { applicationClientTokens.clear(); applicationAssignments.clear(); applicationAccessScopes.clear(); + applicationAuthenticationMethods.clear(); instances.clear(); instanceClientTokens.clear(); accessControlAttributeConfigurations.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAuthenticationMethod.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAuthenticationMethod.java new file mode 100644 index 0000000000..78792821ed --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationAuthenticationMethod.java @@ -0,0 +1,10 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import com.fasterxml.jackson.databind.JsonNode; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ApplicationAuthenticationMethod( + String applicationArn, + String authenticationMethodType, + JsonNode authenticationMethod) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index a139d34445..6595c15b82 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -197,6 +197,35 @@ void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); } + @Test + void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { + String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Delete Authentication Integration\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(appRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationAuthenticationMethod") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"AuthenticationMethodType\":\"IAM\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationAuthenticationMethod") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"AuthenticationMethodType\":\"SAML\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); + } + @Test void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 657b6a70c9..61c4bb189c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -9,6 +9,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentDeletionOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAuthenticationMethod; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; @@ -33,11 +34,13 @@ class SsoAdminServiceTest { private SsoAdminService service; private OrganizationsService organizationsService; private InMemoryStorage applicationAccessScopes; + private InMemoryStorage applicationAuthenticationMethods; @BeforeEach void setUp() { organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); applicationAccessScopes = new InMemoryStorage<>(); + applicationAuthenticationMethods = new InMemoryStorage<>(); service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), @@ -48,6 +51,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), applicationAccessScopes, + applicationAuthenticationMethods, new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -258,6 +262,27 @@ void deleteApplicationAccessScopeDeletesStoredScopeAndValidatesRequest() { assertError("ValidationException", () -> service.deleteApplicationAccessScope(invalid)); } + @Test + void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { + SsoApplication application = createApplication("Authentication App", "authentication-app-token"); + String key = SsoAdminService.applicationAuthenticationMethodKey(application.applicationArn(), "IAM"); + ObjectNode method = mapper.createObjectNode(); + method.putObject("Iam").putObject("ActorPolicy").put("Version", "2012-10-17"); + applicationAuthenticationMethods.put(key, new ApplicationAuthenticationMethod( + application.applicationArn(), "IAM", method)); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("AuthenticationMethodType", "IAM"); + service.deleteApplicationAuthenticationMethod(request); + assertTrue(applicationAuthenticationMethods.get(key).isEmpty()); + assertError("ResourceNotFoundException", () -> service.deleteApplicationAuthenticationMethod(request)); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("AuthenticationMethodType", "SAML"); + assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); + } + @Test void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { SsoApplication application = createApplication("Assignment App", "assignment-app-token"); @@ -557,6 +582,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 51b6bdeda3bc3289bf79efab8a2955450ee9b157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:28:41 -0400 Subject: [PATCH 13/99] feat(ssoadmin): delete application grants --- .../test/SsoAdminAccountAssignmentTest.java | 24 ++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 33 +++++++++++++++++++ .../ssoadmin/model/ApplicationGrant.java | 7 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 29 ++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 26 +++++++++++++++ 7 files changed, 126 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationGrant.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c8845e3203..a6a263e4e5 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -185,6 +185,30 @@ void deleteApplicationAuthenticationMethodUsesAwsSdk() { } } + @Test + @DisplayName("deletes application grants through the AWS SDK") + void deleteApplicationGrantUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses the emulator IAM Identity Center instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SDK Delete Grant")) + .applicationArn(); + + assertThatThrownBy(() -> sso.deleteApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> sso.deleteApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("client_credentials"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ValidationException.class); + } + } + @Test @DisplayName("creates trusted token issuers through the AWS SDK") void createTrustedTokenIssuerUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 498e092851..546d556e39 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -21,6 +21,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | +| `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | | `DescribePermissionSet` | Describes a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index cdee9a1f6c..0b531dcd24 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -37,6 +37,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); + case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); @@ -130,6 +131,11 @@ private Response deleteApplicationAuthenticationMethod(JsonNode request) { return Response.ok().build(); } + private Response deleteApplicationGrant(JsonNode request) { + service.deleteApplicationGrant(request); + return Response.ok().build(); + } + private Response createApplication(JsonNode request, String callerAccountId, String region) { SsoApplication application = service.createApplication(request, callerAccountId, region); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6204cba474..8d2efc0c3d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -15,6 +15,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAuthenticationMethod; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationGrant; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationPortalOptions; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationSignInOptions; import io.github.hectorvent.floci.services.ssoadmin.model.AccessControlAttribute; @@ -72,6 +73,9 @@ public class SsoAdminService implements Resettable { private static final int APPLICATION_QUOTA = 7000; private static final int APPLICATION_GROUP_ASSIGNMENT_QUOTA = 100; private static final int TRUSTED_TOKEN_ISSUER_QUOTA = 10; + private static final Set APPLICATION_GRANT_TYPES = Set.of( + "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:token-exchange"); private static final int MAX_INLINE_POLICY_BYTES = 32_768; private static final int MAX_INLINE_NON_WHITESPACE = 10_240; private static final Set PRINCIPAL_TYPES = Set.of("USER", "GROUP"); @@ -86,6 +90,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationAssignments; private final StorageBackend applicationAccessScopes; private final StorageBackend applicationAuthenticationMethods; + private final StorageBackend applicationGrants; private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend accessControlAttributeConfigurations; @@ -109,6 +114,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-access-scopes.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-authentication-methods.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-grants.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), @@ -129,6 +135,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend applicationAssignments, StorageBackend applicationAccessScopes, StorageBackend applicationAuthenticationMethods, + StorageBackend applicationGrants, StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend accessControlAttributeConfigurations, @@ -147,6 +154,7 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.applicationAssignments = applicationAssignments; this.applicationAccessScopes = applicationAccessScopes; this.applicationAuthenticationMethods = applicationAuthenticationMethods; + this.applicationGrants = applicationGrants; this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; @@ -412,6 +420,12 @@ public synchronized void deleteApplication(String applicationArn) { applicationAuthenticationMethods.delete(key); } } + for (String key : new java.util.ArrayList<>(applicationGrants.keys())) { + ApplicationGrant grant = applicationGrants.get(key).orElse(null); + if (grant != null && applicationArn.equals(grant.applicationArn())) { + applicationGrants.delete(key); + } + } } public synchronized void deleteApplicationAccessScope(JsonNode request) { @@ -457,6 +471,20 @@ public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) applicationAuthenticationMethods.delete(key); } + public synchronized void deleteApplicationGrant(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String grantType = required(request, "GrantType"); + if (!APPLICATION_GRANT_TYPES.contains(grantType)) { + throw validation("GrantType is invalid."); + } + String key = applicationGrantKey(applicationArn, grantType); + if (applicationGrants.get(key).isEmpty()) { + throw notFound("Application grant not found: " + grantType); + } + applicationGrants.delete(key); + } + public synchronized ApplicationAssignment createApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); @@ -970,6 +998,10 @@ static String applicationAuthenticationMethodKey(String applicationArn, String a return applicationArn + "\n" + authenticationMethodType; } + static String applicationGrantKey(String applicationArn, String grantType) { + return applicationArn + "\n" + grantType; + } + private static String validateRegionName(String value) { if (value == null || value.length() > 32 || !REGION_NAME.matcher(value).matches()) { throw validation("RegionName must be 1-32 characters and use an AWS Region name format."); @@ -1034,6 +1066,7 @@ public void clear() { applicationAssignments.clear(); applicationAccessScopes.clear(); applicationAuthenticationMethods.clear(); + applicationGrants.clear(); instances.clear(); instanceClientTokens.clear(); accessControlAttributeConfigurations.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationGrant.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationGrant.java new file mode 100644 index 0000000000..453d8c7743 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/ApplicationGrant.java @@ -0,0 +1,7 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import com.fasterxml.jackson.databind.JsonNode; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record ApplicationGrant(String applicationArn, String grantType, JsonNode grant) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 6595c15b82..470e7b5c2f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -226,6 +226,35 @@ void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); } + @Test + void deleteApplicationGrantValidatesGrantTypeAndMissingGrant() { + String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Delete Grant Integration\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(appRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationGrant") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"GrantType\":\"authorization_code\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteApplicationGrant") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"GrantType\":\"client_credentials\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); + } + @Test void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 61c4bb189c..69b5821c55 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -10,6 +10,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAuthenticationMethod; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationGrant; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; @@ -35,12 +36,14 @@ class SsoAdminServiceTest { private OrganizationsService organizationsService; private InMemoryStorage applicationAccessScopes; private InMemoryStorage applicationAuthenticationMethods; + private InMemoryStorage applicationGrants; @BeforeEach void setUp() { organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); applicationAccessScopes = new InMemoryStorage<>(); applicationAuthenticationMethods = new InMemoryStorage<>(); + applicationGrants = new InMemoryStorage<>(); service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), @@ -52,6 +55,7 @@ void setUp() { new InMemoryStorage(), applicationAccessScopes, applicationAuthenticationMethods, + applicationGrants, new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -283,6 +287,27 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void deleteApplicationGrantDeletesConfiguredGrantAndValidatesGrantType() { + SsoApplication application = createApplication("Grant App", "grant-app-token"); + String grantType = "authorization_code"; + String key = SsoAdminService.applicationGrantKey(application.applicationArn(), grantType); + ObjectNode grant = mapper.createObjectNode(); + grant.putObject("AuthorizationCode"); + applicationGrants.put(key, new ApplicationGrant(application.applicationArn(), grantType, grant)); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("GrantType", grantType); + service.deleteApplicationGrant(request); + assertTrue(applicationGrants.get(key).isEmpty()); + assertError("ResourceNotFoundException", () -> service.deleteApplicationGrant(request)); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("GrantType", "client_credentials"); + assertError("ValidationException", () -> service.deleteApplicationGrant(invalidType)); + } + @Test void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { SsoApplication application = createApplication("Assignment App", "assignment-app-token"); @@ -583,6 +608,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From ac312f6e024bfc65af4dfb8510882ff442e4fa4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:47:03 -0400 Subject: [PATCH 14/99] feat(ssoadmin): delete identity center instances --- .../test/SsoAdminAccountAssignmentTest.java | 21 +++++ docs/services/ssoadmin.md | 1 + .../identitystore/IdentityStoreService.java | 13 +++ .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 82 +++++++++++++++++-- .../ssoadmin/SsoAdminIntegrationTest.java | 30 +++++++ .../ssoadmin/SsoAdminServiceTest.java | 52 ++++++++++++ 7 files changed, 197 insertions(+), 8 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index a6a263e4e5..f0b205fc18 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -305,6 +305,27 @@ void createInstanceQuotaUsesAwsSdk() { } } + @Test + @DisplayName("deletes an account instance through the AWS SDK") + void deleteInstanceUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses an emulator-only account instance"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient("555566667777")) { + String instanceArn = sso.createInstance(request -> request + .name("SdkDisposableInstance") + .clientToken("sdk-delete-instance")) + .instanceArn(); + sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("SdkDisposableApplication")); + + var deleted = sso.deleteInstance(request -> request.instanceArn(instanceArn)); + assertThat(deleted.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.listInstances(request -> {}).instances()).isEmpty(); + } + } + @Test @DisplayName("deletes application access scopes through the AWS SDK") void deleteApplicationAccessScopeUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 546d556e39..d6269db8fc 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -12,6 +12,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | +| `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index 80102d5b04..b995b469c6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java @@ -301,6 +301,19 @@ public List validateGroupIds(JsonNode groupIds) { .toList(); } + public synchronized void deleteIdentityStore(String storeId) { + storeId = requireStore(storeId); + for (Membership membership : listMembershipsAll(storeId)) { + memberships.deleteForAccount(GLOBAL_PARTITION, membershipKey(storeId, membership.membershipId())); + } + for (Group group : scanGroups(storeId)) { + groups.deleteForAccount(GLOBAL_PARTITION, groupKey(storeId, group.groupId())); + } + for (User user : scanUsers(storeId)) { + users.deleteForAccount(GLOBAL_PARTITION, userKey(storeId, user.userId())); + } + } + @Override public void clear() { groups.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 0b531dcd24..3f017aa32f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -28,6 +28,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, return switch (action) { case "ListInstances" -> listInstances(callerAccountId); case "CreateInstance" -> createInstance(request, callerAccountId, region); + case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "AddRegion" -> addRegion(request); @@ -90,6 +91,11 @@ private Response createInstance(JsonNode request, String callerAccountId, String return Response.ok(mapper.createObjectNode().put("InstanceArn", instance.instanceArn())).build(); } + private Response deleteInstance(JsonNode request, String callerAccountId) { + service.deleteInstance(request, callerAccountId); + return Response.ok().build(); + } + private Response createInstanceAccessControlAttributeConfiguration(JsonNode request) { service.createInstanceAccessControlAttributeConfiguration(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 8d2efc0c3d..c28ef7d348 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -27,6 +27,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; import io.github.hectorvent.floci.services.ssoadmin.model.TrustedTokenIssuer; +import io.github.hectorvent.floci.services.identitystore.IdentityStoreService; import io.github.hectorvent.floci.services.organizations.OrganizationsService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -93,16 +94,18 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationGrants; private final StorageBackend instances; private final StorageBackend instanceClientTokens; + private final StorageBackend instanceDeletionMarkers; private final StorageBackend accessControlAttributeConfigurations; private final StorageBackend trustedTokenIssuers; private final StorageBackend trustedTokenIssuerClientTokens; + private final IdentityStoreService identityStoreService; private final OrganizationsService organizationsService; private final String defaultAccountId; private final String defaultRegion; @Inject - public SsoAdminService(StorageFactory storageFactory, OrganizationsService organizationsService, - EmulatorConfig config) { + public SsoAdminService(StorageFactory storageFactory, IdentityStoreService identityStoreService, + OrganizationsService organizationsService, EmulatorConfig config) { this( storageFactory.create("ssoadmin", "ssoadmin-permission-sets.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), @@ -117,9 +120,11 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ storageFactory.create("ssoadmin", "ssoadmin-application-grants.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-instance-deletion-markers.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuers.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuer-client-tokens.json", new TypeReference>() {}), + identityStoreService, organizationsService, config.defaultAccountId(), config.defaultRegion()); @@ -138,9 +143,11 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ StorageBackend applicationGrants, StorageBackend instances, StorageBackend instanceClientTokens, + StorageBackend instanceDeletionMarkers, StorageBackend accessControlAttributeConfigurations, StorageBackend trustedTokenIssuers, StorageBackend trustedTokenIssuerClientTokens, + IdentityStoreService identityStoreService, OrganizationsService organizationsService, String defaultAccountId, String defaultRegion) { @@ -157,9 +164,11 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ this.applicationGrants = applicationGrants; this.instances = instances; this.instanceClientTokens = instanceClientTokens; + this.instanceDeletionMarkers = instanceDeletionMarkers; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; this.trustedTokenIssuers = trustedTokenIssuers; this.trustedTokenIssuerClientTokens = trustedTokenIssuerClientTokens; + this.identityStoreService = identityStoreService; this.organizationsService = organizationsService; this.defaultAccountId = defaultAccountId; this.defaultRegion = defaultRegion; @@ -170,6 +179,9 @@ public SsoAdminService(StorageFactory storageFactory, OrganizationsService organ public String getIdentityStoreId() { return IDENTITY_STORE_ID; } void ensureBootstrapInstance(String ownerAccountId, String region) { + if (instanceDeletionMarkers.get(ownerAccountId).orElse(false)) { + return; + } if (instances.get(ownerAccountId).isEmpty()) { instances.put(ownerAccountId, new SsoInstance(INSTANCE_ARN, IDENTITY_STORE_ID, "floci-identity-center", ownerAccountId, region, System.currentTimeMillis(), "ACTIVE", null, false, @@ -390,10 +402,59 @@ public synchronized SsoInstance createInstance(JsonNode request, String callerAc SsoInstance instance = new SsoInstance(instanceArn, identityStoreId, name, callerAccountId, region, System.currentTimeMillis(), "ACTIVE", null, true, tags); instances.put(callerAccountId, instance); + instanceDeletionMarkers.delete(callerAccountId); instanceClientTokens.put(tokenKey, instanceArn); return instance; } + public synchronized void deleteInstance(JsonNode request, String callerAccountId) { + validateAccountId(callerAccountId); + String instanceArn = required(request, "InstanceArn"); + if (instanceArn.length() < 10 || instanceArn.length() > 1224 || !INSTANCE_ARN_PATTERN.matcher(instanceArn).matches()) { + throw validation("InstanceArn is invalid."); + } + SsoInstance instance = findInstanceByArn(instanceArn); + if (instance == null || !callerAccountId.equals(instance.ownerAccountId())) { + throw accessDenied("Only the account that owns the IAM Identity Center instance can delete it."); + } + if (!regions.scan(key -> true).isEmpty()) { + throw conflict("Remove all additional IAM Identity Center Regions before deleting the instance."); + } + List applicationArns = applications.scan(key -> true).stream() + .filter(application -> instanceArn.equals(application.instanceArn())) + .map(SsoApplication::applicationArn) + .toList(); + for (String applicationArn : applicationArns) { + deleteApplication(applicationArn); + } + + permissionSets.clear(); + assignments.clear(); + assignmentOperations.clear(); + assignmentDeletionOperations.clear(); + accessControlAttributeConfigurations.delete(instanceArn); + for (String key : new java.util.ArrayList<>(trustedTokenIssuers.keys())) { + TrustedTokenIssuer issuer = trustedTokenIssuers.get(key).orElse(null); + if (issuer != null && instanceArn.equals(issuer.instanceArn())) { + trustedTokenIssuers.delete(key); + } + } + for (String key : new java.util.ArrayList<>(trustedTokenIssuerClientTokens.keys())) { + String issuerArn = trustedTokenIssuerClientTokens.get(key).orElse(null); + if (issuerArn != null && trustedTokenIssuers.get(issuerArn).isEmpty()) { + trustedTokenIssuerClientTokens.delete(key); + } + } + for (String key : new java.util.ArrayList<>(instanceClientTokens.keys())) { + if (instanceArn.equals(instanceClientTokens.get(key).orElse(null))) { + instanceClientTokens.delete(key); + } + } + identityStoreService.deleteIdentityStore(instance.identityStoreId()); + instances.delete(callerAccountId); + instanceDeletionMarkers.put(callerAccountId, true); + } + public synchronized void deleteApplication(String applicationArn) { getApplication(applicationArn); applications.delete(applicationArn); @@ -509,7 +570,8 @@ public synchronized ApplicationAssignment createApplicationAssignment(JsonNode r } public synchronized SsoApplication createApplication(JsonNode request, String callerAccountId, String region) { - requireInstance(required(request, "InstanceArn")); + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); validateAccountId(callerAccountId); validateRegionName(region); String providerArn = required(request, "ApplicationProviderArn"); @@ -552,11 +614,14 @@ public synchronized SsoApplication createApplication(JsonNode request, String ca throw quota("The IAM Identity Center application quota has been exceeded."); } - String applicationArn = "arn:aws:sso::" + callerAccountId - + ":application/ssoins-7223b02a5d9f7c8e/apl-" + shortId(); - String identityStoreArn = "arn:aws:identitystore::" + callerAccountId + ":identitystore/" + IDENTITY_STORE_ID; - SsoApplication application = new SsoApplication(callerAccountId, applicationArn, providerArn, - System.currentTimeMillis(), region, description, identityStoreArn, INSTANCE_ARN, name, + String instanceId = instanceArn.substring(instanceArn.lastIndexOf('/') + 1); + String ownerAccountId = instance.ownerAccountId() == null ? callerAccountId : instance.ownerAccountId(); + String applicationArn = "arn:aws:sso::" + ownerAccountId + + ":application/" + instanceId + "/apl-" + shortId(); + String identityStoreArn = "arn:aws:identitystore::" + ownerAccountId + + ":identitystore/" + instance.identityStoreId(); + SsoApplication application = new SsoApplication(ownerAccountId, applicationArn, providerArn, + System.currentTimeMillis(), region, description, identityStoreArn, instanceArn, name, portalOptions, status, tags); applications.put(applicationArn, application); if (tokenKey != null) { @@ -1069,6 +1134,7 @@ public void clear() { applicationGrants.clear(); instances.clear(); instanceClientTokens.clear(); + instanceDeletionMarkers.clear(); accessControlAttributeConfigurations.clear(); trustedTokenIssuers.clear(); trustedTokenIssuerClientTokens.clear(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 470e7b5c2f..2c52198fb8 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -354,6 +354,36 @@ void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { .body("Instances[0].Regions[0].IsPrimaryRegion", equalTo(true)) .body("Instances[0].Regions[0].Status", equalTo("ACTIVE")); } + @Test + void deleteInstanceReturnsEmptyResponseAndRemovesAccountInstance() { + String auth = "AWS4-HMAC-SHA256 Credential=666677778888/20260101/us-east-1/sso/aws4_request"; + String instanceArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.CreateInstance") + .body("{\"Name\":\"DisposableIntegrationInstance\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("InstanceArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.DeleteInstance") + .body("{\"InstanceArn\":\"" + instanceArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.ListInstances") + .body("{}") + .when().post("/") + .then().statusCode(200) + .body("Instances.size()", equalTo(0)); + } @Test void deleteApplicationReturnsEmptyResponseAndRemovesApplication() { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 69b5821c55..faf5f028f4 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -17,6 +17,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; import io.github.hectorvent.floci.services.ssoadmin.model.TrustedTokenIssuer; +import io.github.hectorvent.floci.services.identitystore.IdentityStoreService; import io.github.hectorvent.floci.services.organizations.OrganizationsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,6 +34,7 @@ class SsoAdminServiceTest { private final ObjectMapper mapper = new ObjectMapper(); private SsoAdminService service; + private IdentityStoreService identityStoreService; private OrganizationsService organizationsService; private InMemoryStorage applicationAccessScopes; private InMemoryStorage applicationAuthenticationMethods; @@ -40,6 +42,7 @@ class SsoAdminServiceTest { @BeforeEach void setUp() { + identityStoreService = org.mockito.Mockito.mock(IdentityStoreService.class); organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); applicationAccessScopes = new InMemoryStorage<>(); applicationAuthenticationMethods = new InMemoryStorage<>(); @@ -58,9 +61,11 @@ void setUp() { applicationGrants, new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + identityStoreService, organizationsService, ACCOUNT_ID, "us-east-1"); @@ -158,6 +163,51 @@ void createInstanceAccessControlAttributeConfigurationRejectsInvalidAttributes() () -> service.createInstanceAccessControlAttributeConfiguration(invalidSourceCount)); } + @Test + void deleteInstanceRequiresOwnerAndAllowsRecreation() { + SsoAdminService emptyService = emptyService(); + ObjectNode create = mapper.createObjectNode(); + create.put("Name", "DisposableInstance"); + create.put("ClientToken", "delete-instance-token"); + SsoInstance instance = emptyService.createInstance(create, ACCOUNT_ID, "us-west-2"); + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", instance.instanceArn()); + assertError("AccessDeniedException", () -> emptyService.deleteInstance(request, "210987654321")); + + emptyService.deleteInstance(request, ACCOUNT_ID); + assertTrue(emptyService.listInstances(ACCOUNT_ID).isEmpty()); + assertError("AccessDeniedException", () -> emptyService.deleteInstance(request, ACCOUNT_ID)); + + ObjectNode recreate = mapper.createObjectNode(); + recreate.put("Name", "ReplacementInstance"); + recreate.put("ClientToken", "replacement-instance-token"); + SsoInstance replacement = emptyService.createInstance(recreate, ACCOUNT_ID, "us-west-2"); + assertFalse(replacement.instanceArn().equals(instance.instanceArn())); + } + + @Test + void deleteInstanceRejectsMalformedArnAndCascadesOwnedResources() { + SsoAdminService emptyService = emptyService(); + ObjectNode malformed = mapper.createObjectNode(); + malformed.put("InstanceArn", "not-an-arn"); + assertError("ValidationException", () -> emptyService.deleteInstance(malformed, ACCOUNT_ID)); + + SsoInstance instance = emptyService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1"); + ObjectNode application = mapper.createObjectNode(); + application.put("InstanceArn", instance.instanceArn()); + application.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + application.put("Name", "AttachedApplication"); + SsoApplication createdApplication = emptyService.createApplication(application, ACCOUNT_ID, "us-east-1"); + assertEquals(instance.instanceArn(), createdApplication.instanceArn()); + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", instance.instanceArn()); + emptyService.deleteInstance(request, ACCOUNT_ID); + assertError("ResourceNotFoundException", () -> emptyService.getApplication(createdApplication.applicationArn())); + org.mockito.Mockito.verify(identityStoreService).deleteIdentityStore(instance.identityStoreId()); + } + @Test void createInstancePersistsMetadataAndSupportsIdempotentReplay() { SsoAdminService emptyService = emptyService(); @@ -611,9 +661,11 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + identityStoreService, organizationsService, "999999999999", "us-east-1"); From 7610abdffba2d682f6c52b140c7b1a7580a73ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 09:55:43 -0400 Subject: [PATCH 15/99] feat(ssoadmin): list principal account assignments --- .../test/SsoAdminAccountAssignmentTest.java | 48 +++++++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 18 +++++++ .../services/ssoadmin/SsoAdminService.java | 30 ++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 32 +++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 41 ++++++++++++++++ 6 files changed, 170 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index f0b205fc18..393dedd9e7 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -370,6 +370,54 @@ void deleteApplicationUsesAwsSdk() { } } + @Test + @DisplayName("lists account assignments for a user or group through the AWS SDK") + void listAccountAssignmentsForPrincipalUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only account and principal identifiers"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String principalId = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"; + for (String name : java.util.List.of("PrincipalListSdkOne", "PrincipalListSdkTwo")) { + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name(name)) + .permissionSet().permissionSetArn(); + sso.createAccountAssignment(request -> request + .instanceArn(instanceArn) + .targetId("123456789012") + .targetType(TargetType.AWS_ACCOUNT) + .permissionSetArn(permissionSetArn) + .principalType(PrincipalType.GROUP) + .principalId(principalId)); + } + + var first = sso.listAccountAssignmentsForPrincipal(request -> request + .instanceArn(instanceArn) + .principalId(principalId) + .principalType(PrincipalType.GROUP) + .maxResults(1)); + assertThat(first.accountAssignments()).hasSize(1); + assertThat(first.nextToken()).isNotBlank(); + + var second = sso.listAccountAssignmentsForPrincipal(request -> request + .instanceArn(instanceArn) + .principalId(principalId) + .principalType(PrincipalType.GROUP) + .maxResults(1) + .nextToken(first.nextToken())); + assertThat(second.accountAssignments()).hasSize(1); + assertThat(second.nextToken()).isNull(); + + var filtered = sso.listAccountAssignmentsForPrincipal(request -> request + .instanceArn(instanceArn) + .principalId(principalId) + .principalType(PrincipalType.GROUP) + .filter(filter -> filter.accountId("123456789012"))); + assertThat(filtered.accountAssignments()).hasSize(2); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d6269db8fc..67c40b2196 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -34,6 +34,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | +| `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 3f017aa32f..e7c9d3a58d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -50,6 +50,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); case "ListAccountAssignments" -> listAccountAssignments(request); + case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); @@ -236,6 +237,23 @@ private Response listAccountAssignments(JsonNode request) { return Response.ok(response).build(); } + private Response listAccountAssignmentsForPrincipal(JsonNode request, String callerAccountId) { + var page = service.listAssignmentsForPrincipal(request, callerAccountId); + ObjectNode response = mapper.createObjectNode(); + ArrayNode array = response.putArray("AccountAssignments"); + for (Assignment assignment : page.items()) { + array.addObject() + .put("AccountId", assignment.accountId()) + .put("PermissionSetArn", assignment.permissionSetArn()) + .put("PrincipalId", assignment.principalId()) + .put("PrincipalType", assignment.principalType()); + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response createAccountAssignment(JsonNode request) { AssignmentOperation op = service.createAssignment(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index c28ef7d348..f830e1597d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -788,6 +788,36 @@ public List listAssignments(String instanceArn, String accountId, St .sorted(Comparator.comparing(Assignment::principalId)).toList(); } + public PaginatedResult listAssignmentsForPrincipal(JsonNode request, String callerAccountId) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + validateAccountId(callerAccountId); + if (instance.accountInstance() || !callerAccountId.equals(instance.ownerAccountId())) { + throw accessDenied("ListAccountAssignmentsForPrincipal must be called by the owner of an organization instance."); + } + String principalId = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + String accountFilter = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() > 1 || !filter.has("AccountId")) { + throw validation("Filter may contain only AccountId."); + } + accountFilter = validateAccountId(required(filter, "AccountId")); + } + String finalAccountFilter = accountFilter; + List matching = assignments.scan(key -> true).stream() + .filter(a -> principalId.equals(a.principalId()) && principalType.equals(a.principalType())) + .filter(a -> finalAccountFilter == null || finalAccountFilter.equals(a.accountId())) + .toList(); + return Pagination.paginate(matching, + a -> a.accountId() + "::" + a.permissionSetArn(), + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + public synchronized AssignmentOperation createAssignment(JsonNode request) { requireInstance(required(request, "InstanceArn")); String account = validateAccountId(required(request, "TargetId")); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 2c52198fb8..daead6f725 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -414,6 +414,38 @@ void deleteApplicationReturnsEmptyResponseAndRemovesApplication() { .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); } + @Test + void listAccountAssignmentsForPrincipalReturnsPrincipalAccessWithPagination() { + String instanceArn = listInstancesArn(); + String principalId = "cccccccc-dddd-eeee-ffff-000000000001"; + for (String name : java.util.List.of("PrincipalIntegrationOne", "PrincipalIntegrationTwo")) { + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"" + name + "\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + String assignmentRequest = "{\"InstanceArn\":\"" + instanceArn + "\",\"TargetId\":\"123456789012\"," + + "\"TargetType\":\"AWS_ACCOUNT\",\"PermissionSetArn\":\"" + permissionSetArn + "\"," + + "\"PrincipalType\":\"GROUP\",\"PrincipalId\":\"" + principalId + "\"}"; + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateAccountAssignment") + .body(assignmentRequest).when().post("/").then().statusCode(200); + } + + String request = "{\"InstanceArn\":\"" + instanceArn + "\",\"PrincipalId\":\"" + principalId + "\"," + + "\"PrincipalType\":\"GROUP\",\"MaxResults\":1}"; + String nextToken = given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListAccountAssignmentsForPrincipal") + .body(request) + .when().post("/") + .then().statusCode(200) + .body("AccountAssignments.size()", equalTo(1)) + .extract().path("NextToken"); + org.junit.jupiter.api.Assertions.assertNotNull(nextToken); + } @Test void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { String instanceArn = listInstancesArn(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index faf5f028f4..98c2b8c273 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -588,6 +588,47 @@ void duplicateManagedPolicyReturnsConflict() { () -> service.attachPolicy(service.getInstanceArn(), permissionSet.arn(), policyArn)); } + @Test + void listAccountAssignmentsForPrincipalFiltersAndPaginatesUserOrGroupAccess() { + PermissionSet first = createPermissionSet("PrincipalListOne"); + PermissionSet second = createPermissionSet("PrincipalListTwo"); + service.createAssignment(assignmentRequest(first.arn())); + service.createAssignment(assignmentRequest(second.arn())); + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("PrincipalId", PRINCIPAL_ID); + request.put("PrincipalType", "GROUP"); + request.put("MaxResults", 1); + + var firstPage = service.listAssignmentsForPrincipal(request, ACCOUNT_ID); + assertEquals(1, firstPage.items().size()); + assertNotNull(firstPage.nextToken()); + + request.put("NextToken", firstPage.nextToken()); + var secondPage = service.listAssignmentsForPrincipal(request, ACCOUNT_ID); + assertEquals(1, secondPage.items().size()); + assertTrue(secondPage.nextToken() == null); + + request.remove("NextToken"); + request.remove("MaxResults"); + request.putObject("Filter").put("AccountId", ACCOUNT_ID); + assertEquals(2, service.listAssignmentsForPrincipal(request, ACCOUNT_ID).items().size()); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("PrincipalType", "ROLE"); + assertError("ValidationException", () -> service.listAssignmentsForPrincipal(invalidType, ACCOUNT_ID)); + + SsoAdminService accountInstanceService = emptyService(); + SsoInstance accountInstance = accountInstanceService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1"); + ObjectNode accountInstanceRequest = mapper.createObjectNode(); + accountInstanceRequest.put("InstanceArn", accountInstance.instanceArn()); + accountInstanceRequest.put("PrincipalId", PRINCIPAL_ID); + accountInstanceRequest.put("PrincipalType", "USER"); + assertError("AccessDeniedException", + () -> accountInstanceService.listAssignmentsForPrincipal(accountInstanceRequest, ACCOUNT_ID)); + } + @Test void assignmentValidationAndDuplicateDetectionAreModeled() { PermissionSet permissionSet = createPermissionSet("AssignmentAdmins"); From e3295f584881fc0d3422de310673b488c26899f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:04:33 -0400 Subject: [PATCH 16/99] feat(ssoadmin): provision permission sets --- .../test/SsoAdminAccountAssignmentTest.java | 26 ++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 ++++++ .../services/ssoadmin/SsoAdminService.java | 60 ++++++++++++++++++- .../model/PermissionSetProvisioning.java | 6 ++ .../PermissionSetProvisioningOperation.java | 12 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 25 ++++++++ .../ssoadmin/SsoAdminServiceTest.java | 46 ++++++++++++++ 8 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioning.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioningOperation.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 393dedd9e7..9b1e163810 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -418,6 +418,32 @@ void listAccountAssignmentsForPrincipalUsesAwsSdk() { } } + @Test + @DisplayName("provisions permission sets to AWS accounts through the AWS SDK") + void provisionPermissionSetUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only account identifiers"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("ProvisionSdkAdmins")) + .permissionSet().permissionSetArn(); + + var response = sso.provisionPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .targetType("AWS_ACCOUNT") + .targetId("123456789012")); + + assertThat(response.permissionSetProvisioningStatus()).isNotNull(); + assertThat(response.permissionSetProvisioningStatus().statusAsString()).isEqualTo("SUCCEEDED"); + assertThat(response.permissionSetProvisioningStatus().accountId()).isEqualTo("123456789012"); + assertThat(response.permissionSetProvisioningStatus().permissionSetArn()).isEqualTo(permissionSetArn); + assertThat(response.permissionSetProvisioningStatus().requestId()).isNotBlank(); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 67c40b2196..8234f380ba 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -35,6 +35,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | +| `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index e7c9d3a58d..88b1b8b525 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -8,6 +8,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -51,6 +52,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); case "ListAccountAssignments" -> listAccountAssignments(request); case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); + case "ProvisionPermissionSet" -> provisionPermissionSet(request); case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); @@ -254,6 +256,23 @@ private Response listAccountAssignmentsForPrincipal(JsonNode request, String cal return Response.ok(response).build(); } + private Response provisionPermissionSet(JsonNode request) { + PermissionSetProvisioningOperation operation = service.provisionPermissionSet(request); + ObjectNode response = mapper.createObjectNode(); + ObjectNode status = response.putObject("PermissionSetProvisioningStatus"); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + if (operation.accountId() != null) { + status.put("AccountId", operation.accountId()); + } + status.put("PermissionSetArn", operation.permissionSetArn()); + if (operation.failureReason() != null) { + status.put("FailureReason", operation.failureReason()); + } + return Response.ok(response).build(); + } + private Response createAccountAssignment(JsonNode request) { AssignmentOperation op = service.createAssignment(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f830e1597d..3dc0f2d431 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -23,6 +23,8 @@ import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.OidcJwtIssuerConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioning; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; @@ -85,6 +87,8 @@ public class SsoAdminService implements Resettable { private final StorageBackend assignments; private final StorageBackend assignmentOperations; private final StorageBackend assignmentDeletionOperations; + private final StorageBackend permissionSetProvisionings; + private final StorageBackend permissionSetProvisioningOperations; private final StorageBackend regions; private final StorageBackend applications; private final StorageBackend applicationClientTokens; @@ -111,6 +115,8 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-assignment-deletion-operations.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-permission-set-provisionings.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-permission-set-provisioning-operations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), @@ -134,6 +140,8 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend assignments, StorageBackend assignmentOperations, StorageBackend assignmentDeletionOperations, + StorageBackend permissionSetProvisionings, + StorageBackend permissionSetProvisioningOperations, StorageBackend regions, StorageBackend applications, StorageBackend applicationClientTokens, @@ -155,6 +163,8 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.assignments = assignments; this.assignmentOperations = assignmentOperations; this.assignmentDeletionOperations = assignmentDeletionOperations; + this.permissionSetProvisionings = permissionSetProvisionings; + this.permissionSetProvisioningOperations = permissionSetProvisioningOperations; this.regions = regions; this.applications = applications; this.applicationClientTokens = applicationClientTokens; @@ -432,6 +442,8 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId assignments.clear(); assignmentOperations.clear(); assignmentDeletionOperations.clear(); + permissionSetProvisionings.clear(); + permissionSetProvisioningOperations.clear(); accessControlAttributeConfigurations.delete(instanceArn); for (String key : new java.util.ArrayList<>(trustedTokenIssuers.keys())) { TrustedTokenIssuer issuer = trustedTokenIssuers.get(key).orElse(null); @@ -818,14 +830,55 @@ public PaginatedResult listAssignmentsForPrincipal(JsonNode request, optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + public synchronized PermissionSetProvisioningOperation provisionPermissionSet(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Permission sets can only be provisioned from an organization instance."); + } + String permissionSetArn = required(request, "PermissionSetArn"); + getPermissionSet(instanceArn, permissionSetArn); + String targetType = required(request, "TargetType"); + String targetId = text(request, "TargetId"); + if ("AWS_ACCOUNT".equals(targetType)) { + targetId = validateAccountId(required(request, "TargetId")); + ensurePermissionSetProvisioned(permissionSetArn, targetId); + } else if ("ALL_PROVISIONED_ACCOUNTS".equals(targetType)) { + if (targetId != null) { + throw validation("TargetId must not be provided when TargetType is ALL_PROVISIONED_ACCOUNTS."); + } + permissionSetProvisionings.scan(key -> true).stream() + .filter(provisioning -> permissionSetArn.equals(provisioning.permissionSetArn())) + .map(PermissionSetProvisioning::accountId) + .distinct() + .forEach(accountId -> ensurePermissionSetProvisioned(permissionSetArn, accountId)); + } else { + throw validation("TargetType must be AWS_ACCOUNT or ALL_PROVISIONED_ACCOUNTS."); + } + String requestId = UUID.randomUUID().toString(); + PermissionSetProvisioningOperation operation = new PermissionSetProvisioningOperation( + requestId, "SUCCEEDED", System.currentTimeMillis(), targetId, permissionSetArn, null); + permissionSetProvisioningOperations.put(requestId, operation); + return operation; + } + + private void ensurePermissionSetProvisioned(String permissionSetArn, String accountId) { + String key = permissionSetArn + "::" + accountId; + permissionSetProvisionings.put(key, new PermissionSetProvisioning(permissionSetArn, accountId, "SUCCEEDED")); + } + public synchronized AssignmentOperation createAssignment(JsonNode request) { - requireInstance(required(request, "InstanceArn")); + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Account assignments can only be created from an organization instance."); + } String account = validateAccountId(required(request, "TargetId")); if (!"AWS_ACCOUNT".equals(required(request, "TargetType"))) { throw validation("TargetType must be AWS_ACCOUNT."); } String permission = required(request, "PermissionSetArn"); - getPermissionSet(INSTANCE_ARN, permission); + getPermissionSet(instanceArn, permission); String principal = validatePrincipalId(required(request, "PrincipalId")); String principalType = required(request, "PrincipalType"); if (!PRINCIPAL_TYPES.contains(principalType)) { @@ -837,6 +890,7 @@ public synchronized AssignmentOperation createAssignment(JsonNode request) { } Assignment assignment = new Assignment(account, permission, principal, principalType); assignments.put(key, assignment); + ensurePermissionSetProvisioned(permission, account); String requestId = UUID.randomUUID().toString(); AssignmentOperation operation = new AssignmentOperation(requestId, "SUCCEEDED", account, permission, principal, principalType, null); @@ -1155,6 +1209,8 @@ public void clear() { assignments.clear(); assignmentOperations.clear(); assignmentDeletionOperations.clear(); + permissionSetProvisionings.clear(); + permissionSetProvisioningOperations.clear(); regions.clear(); applications.clear(); applicationClientTokens.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioning.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioning.java new file mode 100644 index 0000000000..571c2de666 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioning.java @@ -0,0 +1,6 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PermissionSetProvisioning(String permissionSetArn, String accountId, String status) {} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioningOperation.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioningOperation.java new file mode 100644 index 0000000000..68329bf869 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSetProvisioningOperation.java @@ -0,0 +1,12 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PermissionSetProvisioningOperation( + String requestId, + String status, + long createdDateEpochMillis, + String accountId, + String permissionSetArn, + String failureReason) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index daead6f725..6ca028eec7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -446,6 +446,31 @@ void listAccountAssignmentsForPrincipalReturnsPrincipalAccessWithPagination() { .extract().path("NextToken"); org.junit.jupiter.api.Assertions.assertNotNull(nextToken); } + + @Test + void provisionPermissionSetReturnsSuccessfulOperationStatus() { + String instanceArn = listInstancesArn(); + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"ProvisionIntegration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ProvisionPermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"TargetType\":\"AWS_ACCOUNT\",\"TargetId\":\"123456789012\"}") + .when().post("/") + .then().statusCode(200) + .body("PermissionSetProvisioningStatus.Status", equalTo("SUCCEEDED")) + .body("PermissionSetProvisioningStatus.AccountId", equalTo("123456789012")) + .body("PermissionSetProvisioningStatus.PermissionSetArn", equalTo(permissionSetArn)) + .body("PermissionSetProvisioningStatus.RequestId", matchesPattern("[0-9a-f-]{36}")); + } + @Test void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { String instanceArn = listInstancesArn(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 98c2b8c273..1beb32c857 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -13,6 +13,8 @@ import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationGrant; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioning; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; import io.github.hectorvent.floci.services.ssoadmin.model.SsoInstance; @@ -52,6 +54,8 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -629,6 +633,46 @@ void listAccountAssignmentsForPrincipalFiltersAndPaginatesUserOrGroupAccess() { () -> accountInstanceService.listAssignmentsForPrincipal(accountInstanceRequest, ACCOUNT_ID)); } + @Test + void provisionPermissionSetSupportsSingleAndAllProvisionedAccounts() { + PermissionSet permissionSet = createPermissionSet("ProvisionAdmins"); + + ObjectNode single = mapper.createObjectNode(); + single.put("InstanceArn", service.getInstanceArn()); + single.put("PermissionSetArn", permissionSet.arn()); + single.put("TargetType", "AWS_ACCOUNT"); + single.put("TargetId", ACCOUNT_ID); + PermissionSetProvisioningOperation operation = service.provisionPermissionSet(single); + assertEquals("SUCCEEDED", operation.status()); + assertEquals(ACCOUNT_ID, operation.accountId()); + assertEquals(permissionSet.arn(), operation.permissionSetArn()); + assertTrue(operation.createdDateEpochMillis() > 0); + + ObjectNode assignment = assignmentRequest(permissionSet.arn()); + service.createAssignment(assignment); + ObjectNode all = mapper.createObjectNode(); + all.put("InstanceArn", service.getInstanceArn()); + all.put("PermissionSetArn", permissionSet.arn()); + all.put("TargetType", "ALL_PROVISIONED_ACCOUNTS"); + PermissionSetProvisioningOperation allOperation = service.provisionPermissionSet(all); + assertEquals("SUCCEEDED", allOperation.status()); + assertTrue(allOperation.accountId() == null); + + ObjectNode invalid = single.deepCopy(); + invalid.put("TargetType", "ORGANIZATION"); + assertError("ValidationException", () -> service.provisionPermissionSet(invalid)); + + ObjectNode invalidAll = all.deepCopy(); + invalidAll.put("TargetId", ACCOUNT_ID); + assertError("ValidationException", () -> service.provisionPermissionSet(invalidAll)); + + SsoAdminService accountInstanceService = emptyService(); + SsoInstance accountInstance = accountInstanceService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1"); + ObjectNode accountInstanceRequest = single.deepCopy(); + accountInstanceRequest.put("InstanceArn", accountInstance.instanceArn()); + assertError("AccessDeniedException", () -> accountInstanceService.provisionPermissionSet(accountInstanceRequest)); + } + @Test void assignmentValidationAndDuplicateDetectionAreModeled() { PermissionSet permissionSet = createPermissionSet("AssignmentAdmins"); @@ -693,6 +737,8 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 3023651ef4fa96358c0c3f297e2d5429866a19ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:25:02 -0400 Subject: [PATCH 17/99] feat(ssoadmin): list provisioned permission sets --- .../test/SsoAdminAccountAssignmentTest.java | 25 +++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 12 ++++++ .../services/ssoadmin/SsoAdminService.java | 42 ++++++++++++++++++- .../ssoadmin/SsoAdminIntegrationTest.java | 26 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 40 ++++++++++++++++++ 6 files changed, 145 insertions(+), 1 deletion(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 9b1e163810..280a6f2146 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -444,6 +444,31 @@ void provisionPermissionSetUsesAwsSdk() { } } + @Test + @DisplayName("lists permission sets provisioned to an AWS account through the AWS SDK") + void listPermissionSetsProvisionedToAccountUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only account identifiers"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("ProvisionedListSdkAdmins")) + .permissionSet().permissionSetArn(); + sso.provisionPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .targetType("AWS_ACCOUNT") + .targetId("210987654321")); + + var response = sso.listPermissionSetsProvisionedToAccount(request -> request + .instanceArn(instanceArn) + .accountId("210987654321") + .provisioningStatus("LATEST_PERMISSION_SET_PROVISIONED")); + assertThat(response.permissionSets()).contains(permissionSetArn); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 8234f380ba..0dd3d7ccb5 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -36,6 +36,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | +| `ListPermissionSetsProvisionedToAccount` | Lists permission sets provisioned to an AWS account, including current/stale provisioning status filters and AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 88b1b8b525..afaeae5382 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -53,6 +53,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListAccountAssignments" -> listAccountAssignments(request); case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "ProvisionPermissionSet" -> provisionPermissionSet(request); + case "ListPermissionSetsProvisionedToAccount" -> listPermissionSetsProvisionedToAccount(request); case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); @@ -273,6 +274,17 @@ private Response provisionPermissionSet(JsonNode request) { return Response.ok(response).build(); } + private Response listPermissionSetsProvisionedToAccount(JsonNode request) { + var page = service.listPermissionSetsProvisionedToAccount(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode permissionSets = response.putArray("PermissionSets"); + page.items().forEach(permissionSets::add); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response createAccountAssignment(JsonNode request) { AssignmentOperation op = service.createAssignment(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 3dc0f2d431..222d231ff2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -711,6 +711,7 @@ public synchronized PermissionSet updatePermissionSet(JsonNode request) { new LinkedHashMap<>(current.managedPolicies()), new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy()); permissionSets.put(updated.arn(), updated); + markPermissionSetProvisioningStale(updated.arn()); return updated; } @@ -725,6 +726,7 @@ public synchronized void attachPolicy(String instanceArn, String arn, String pol } current.managedPolicies().put(policyArn, policyArn.substring(policyArn.lastIndexOf('/') + 1)); permissionSets.put(arn, current); + markPermissionSetProvisioningStale(arn); } public synchronized void attachCustomerManagedPolicyReference(JsonNode request) { @@ -752,6 +754,7 @@ public synchronized void attachCustomerManagedPolicyReference(JsonNode request) } current.customerManagedPolicies().put(key, new CustomerManagedPolicyReference(name, path)); permissionSets.put(current.arn(), current); + markPermissionSetProvisioningStale(current.arn()); } public synchronized void detachPolicy(String instanceArn, String arn, String policyArn) { @@ -761,6 +764,7 @@ public synchronized void detachPolicy(String instanceArn, String arn, String pol throw conflict("The managed policy is not attached to the permission set."); } permissionSets.put(arn, current); + markPermissionSetProvisioningStale(arn); } public synchronized void putInlinePolicy(String instanceArn, String arn, String policy) { @@ -769,6 +773,7 @@ public synchronized void putInlinePolicy(String instanceArn, String arn, String permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), new LinkedHashMap<>(current.customerManagedPolicies()), policy)); + markPermissionSetProvisioningStale(arn); } public synchronized void deleteInlinePolicy(String instanceArn, String arn) { @@ -776,6 +781,7 @@ public synchronized void deleteInlinePolicy(String instanceArn, String arn) { permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), new LinkedHashMap<>(current.customerManagedPolicies()), null)); + markPermissionSetProvisioningStale(arn); } public PaginatedResult listAssignments(JsonNode request) { @@ -864,7 +870,41 @@ public synchronized PermissionSetProvisioningOperation provisionPermissionSet(Js private void ensurePermissionSetProvisioned(String permissionSetArn, String accountId) { String key = permissionSetArn + "::" + accountId; - permissionSetProvisionings.put(key, new PermissionSetProvisioning(permissionSetArn, accountId, "SUCCEEDED")); + permissionSetProvisionings.put(key, new PermissionSetProvisioning( + permissionSetArn, accountId, "LATEST_PERMISSION_SET_PROVISIONED")); + } + + public PaginatedResult listPermissionSetsProvisionedToAccount(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Permission sets are only available from an organization instance."); + } + String accountId = validateAccountId(required(request, "AccountId")); + String provisioningStatus = text(request, "ProvisioningStatus"); + if (provisioningStatus != null + && !Set.of("LATEST_PERMISSION_SET_PROVISIONED", "LATEST_PERMISSION_SET_NOT_PROVISIONED") + .contains(provisioningStatus)) { + throw validation("ProvisioningStatus is invalid."); + } + List permissionSetArns = permissionSetProvisionings.scan(key -> true).stream() + .filter(provisioning -> accountId.equals(provisioning.accountId())) + .filter(provisioning -> provisioningStatus == null || provisioningStatus.equals(provisioning.status())) + .map(PermissionSetProvisioning::permissionSetArn) + .distinct() + .toList(); + return Pagination.paginate(permissionSetArns, value -> value, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + + private void markPermissionSetProvisioningStale(String permissionSetArn) { + for (PermissionSetProvisioning provisioning : permissionSetProvisionings.scan(key -> true)) { + if (permissionSetArn.equals(provisioning.permissionSetArn())) { + permissionSetProvisionings.put(permissionSetArn + "::" + provisioning.accountId(), + new PermissionSetProvisioning(permissionSetArn, provisioning.accountId(), + "LATEST_PERMISSION_SET_NOT_PROVISIONED")); + } + } } public synchronized AssignmentOperation createAssignment(JsonNode request) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 6ca028eec7..4d18949270 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -471,6 +471,32 @@ void provisionPermissionSetReturnsSuccessfulOperationStatus() { .body("PermissionSetProvisioningStatus.RequestId", matchesPattern("[0-9a-f-]{36}")); } + @Test + void listPermissionSetsProvisionedToAccountReturnsProvisionedArns() { + String instanceArn = listInstancesArn(); + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"ProvisionedListIntegration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ProvisionPermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"TargetType\":\"AWS_ACCOUNT\",\"TargetId\":\"210987654321\"}") + .when().post("/").then().statusCode(200); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListPermissionSetsProvisionedToAccount") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"AccountId\":\"210987654321\"," + + "\"ProvisioningStatus\":\"LATEST_PERMISSION_SET_PROVISIONED\"}") + .when().post("/") + .then().statusCode(200) + .body("PermissionSets", org.hamcrest.Matchers.hasItem(permissionSetArn)); + } + @Test void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { String instanceArn = listInstancesArn(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 1beb32c857..252f349409 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -673,6 +673,46 @@ void provisionPermissionSetSupportsSingleAndAllProvisionedAccounts() { assertError("AccessDeniedException", () -> accountInstanceService.provisionPermissionSet(accountInstanceRequest)); } + @Test + void listPermissionSetsProvisionedToAccountFiltersCurrentAndStaleProvisioning() { + PermissionSet first = createPermissionSet("ProvisionedListOne"); + PermissionSet second = createPermissionSet("ProvisionedListTwo"); + for (PermissionSet permissionSet : java.util.List.of(first, second)) { + ObjectNode provision = mapper.createObjectNode(); + provision.put("InstanceArn", service.getInstanceArn()); + provision.put("PermissionSetArn", permissionSet.arn()); + provision.put("TargetType", "AWS_ACCOUNT"); + provision.put("TargetId", ACCOUNT_ID); + service.provisionPermissionSet(provision); + } + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("AccountId", ACCOUNT_ID); + request.put("MaxResults", 1); + var firstPage = service.listPermissionSetsProvisionedToAccount(request); + assertEquals(1, firstPage.items().size()); + assertNotNull(firstPage.nextToken()); + + request.remove("MaxResults"); + request.remove("NextToken"); + request.put("ProvisioningStatus", "LATEST_PERMISSION_SET_PROVISIONED"); + assertEquals(2, service.listPermissionSetsProvisionedToAccount(request).items().size()); + + ObjectNode update = mapper.createObjectNode(); + update.put("InstanceArn", service.getInstanceArn()); + update.put("PermissionSetArn", first.arn()); + update.put("Description", "Changed after provisioning"); + service.updatePermissionSet(update); + assertEquals(1, service.listPermissionSetsProvisionedToAccount(request).items().size()); + + request.put("ProvisioningStatus", "LATEST_PERMISSION_SET_NOT_PROVISIONED"); + assertEquals(java.util.List.of(first.arn()), service.listPermissionSetsProvisionedToAccount(request).items()); + + request.put("ProvisioningStatus", "FAILED"); + assertError("ValidationException", () -> service.listPermissionSetsProvisionedToAccount(request)); + } + @Test void assignmentValidationAndDuplicateDetectionAreModeled() { PermissionSet permissionSet = createPermissionSet("AssignmentAdmins"); From de94266a8b83ddbf7ec1d772f9b35f6670240116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:29:34 -0400 Subject: [PATCH 18/99] feat(ssoadmin): list accounts for provisioned permission set --- .../test/SsoAdminAccountAssignmentTest.java | 25 ++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 12 ++++++ .../services/ssoadmin/SsoAdminService.java | 24 ++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 26 +++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 39 +++++++++++++++++++ 6 files changed, 127 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 280a6f2146..2d43f22e38 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -469,6 +469,31 @@ void listPermissionSetsProvisionedToAccountUsesAwsSdk() { } } + @Test + @DisplayName("lists accounts for a provisioned permission set through the AWS SDK") + void listAccountsForProvisionedPermissionSetUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only account identifiers"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("ProvisionedAccountsSdkAdmins")) + .permissionSet().permissionSetArn(); + sso.provisionPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .targetType("AWS_ACCOUNT") + .targetId("321098765432")); + + var response = sso.listAccountsForProvisionedPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .provisioningStatus("LATEST_PERMISSION_SET_PROVISIONED")); + assertThat(response.accountIds()).contains("321098765432"); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 0dd3d7ccb5..17e3f9ae2f 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -37,6 +37,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | | `ListPermissionSetsProvisionedToAccount` | Lists permission sets provisioned to an AWS account, including current/stale provisioning status filters and AWS-compatible pagination. | +| `ListAccountsForProvisionedPermissionSet` | Lists AWS accounts where a permission set is provisioned, including current/stale provisioning status filters and AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index afaeae5382..f8f6408dd4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -54,6 +54,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "ProvisionPermissionSet" -> provisionPermissionSet(request); case "ListPermissionSetsProvisionedToAccount" -> listPermissionSetsProvisionedToAccount(request); + case "ListAccountsForProvisionedPermissionSet" -> listAccountsForProvisionedPermissionSet(request); case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); @@ -285,6 +286,17 @@ private Response listPermissionSetsProvisionedToAccount(JsonNode request) { return Response.ok(response).build(); } + private Response listAccountsForProvisionedPermissionSet(JsonNode request) { + var page = service.listAccountsForProvisionedPermissionSet(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode accountIds = response.putArray("AccountIds"); + page.items().forEach(accountIds::add); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response createAccountAssignment(JsonNode request) { AssignmentOperation op = service.createAssignment(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 222d231ff2..936fb9c045 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -897,6 +897,30 @@ public PaginatedResult listPermissionSetsProvisionedToAccount(JsonNode r optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + public PaginatedResult listAccountsForProvisionedPermissionSet(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Permission sets are only available from an organization instance."); + } + String permissionSetArn = required(request, "PermissionSetArn"); + getPermissionSet(instanceArn, permissionSetArn); + String provisioningStatus = text(request, "ProvisioningStatus"); + if (provisioningStatus != null + && !Set.of("LATEST_PERMISSION_SET_PROVISIONED", "LATEST_PERMISSION_SET_NOT_PROVISIONED") + .contains(provisioningStatus)) { + throw validation("ProvisioningStatus is invalid."); + } + List accountIds = permissionSetProvisionings.scan(key -> true).stream() + .filter(provisioning -> permissionSetArn.equals(provisioning.permissionSetArn())) + .filter(provisioning -> provisioningStatus == null || provisioningStatus.equals(provisioning.status())) + .map(PermissionSetProvisioning::accountId) + .distinct() + .toList(); + return Pagination.paginate(accountIds, value -> value, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + private void markPermissionSetProvisioningStale(String permissionSetArn) { for (PermissionSetProvisioning provisioning : permissionSetProvisionings.scan(key -> true)) { if (permissionSetArn.equals(provisioning.permissionSetArn())) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 4d18949270..48fc1fe19c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -497,6 +497,32 @@ void listPermissionSetsProvisionedToAccountReturnsProvisionedArns() { .body("PermissionSets", org.hamcrest.Matchers.hasItem(permissionSetArn)); } + @Test + void listAccountsForProvisionedPermissionSetReturnsAccountIds() { + String instanceArn = listInstancesArn(); + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"ProvisionedAccountsIntegration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ProvisionPermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"TargetType\":\"AWS_ACCOUNT\",\"TargetId\":\"321098765432\"}") + .when().post("/").then().statusCode(200); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListAccountsForProvisionedPermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"," + + "\"ProvisioningStatus\":\"LATEST_PERMISSION_SET_PROVISIONED\"}") + .when().post("/") + .then().statusCode(200) + .body("AccountIds", org.hamcrest.Matchers.hasItem("321098765432")); + } + @Test void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { String instanceArn = listInstancesArn(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 252f349409..dfe38229d3 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -713,6 +713,45 @@ void listPermissionSetsProvisionedToAccountFiltersCurrentAndStaleProvisioning() assertError("ValidationException", () -> service.listPermissionSetsProvisionedToAccount(request)); } + @Test + void listAccountsForProvisionedPermissionSetFiltersCurrentAndStaleAccounts() { + PermissionSet permissionSet = createPermissionSet("ProvisionedAccountsList"); + for (String accountId : java.util.List.of(ACCOUNT_ID, "210987654321")) { + ObjectNode provision = mapper.createObjectNode(); + provision.put("InstanceArn", service.getInstanceArn()); + provision.put("PermissionSetArn", permissionSet.arn()); + provision.put("TargetType", "AWS_ACCOUNT"); + provision.put("TargetId", accountId); + service.provisionPermissionSet(provision); + } + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("PermissionSetArn", permissionSet.arn()); + request.put("MaxResults", 1); + var firstPage = service.listAccountsForProvisionedPermissionSet(request); + assertEquals(1, firstPage.items().size()); + assertNotNull(firstPage.nextToken()); + + request.remove("MaxResults"); + request.remove("NextToken"); + request.put("ProvisioningStatus", "LATEST_PERMISSION_SET_PROVISIONED"); + assertEquals(2, service.listAccountsForProvisionedPermissionSet(request).items().size()); + + ObjectNode update = mapper.createObjectNode(); + update.put("InstanceArn", service.getInstanceArn()); + update.put("PermissionSetArn", permissionSet.arn()); + update.put("Description", "Changed after provisioning"); + service.updatePermissionSet(update); + assertEquals(0, service.listAccountsForProvisionedPermissionSet(request).items().size()); + + request.put("ProvisioningStatus", "LATEST_PERMISSION_SET_NOT_PROVISIONED"); + assertEquals(2, service.listAccountsForProvisionedPermissionSet(request).items().size()); + + request.put("ProvisioningStatus", "FAILED"); + assertError("ValidationException", () -> service.listAccountsForProvisionedPermissionSet(request)); + } + @Test void assignmentValidationAndDuplicateDetectionAreModeled() { PermissionSet permissionSet = createPermissionSet("AssignmentAdmins"); From a2e33283f63505a53d41f875488fba9ef84d8e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:33:34 -0400 Subject: [PATCH 19/99] feat(ssoadmin): describe permission set provisioning status --- .../test/SsoAdminAccountAssignmentTest.java | 7 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 22 +++++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 9 ++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 13 +++++++++-- .../ssoadmin/SsoAdminServiceTest.java | 3 +++ 6 files changed, 53 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 2d43f22e38..11545480e4 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -441,6 +441,13 @@ void provisionPermissionSetUsesAwsSdk() { assertThat(response.permissionSetProvisioningStatus().accountId()).isEqualTo("123456789012"); assertThat(response.permissionSetProvisioningStatus().permissionSetArn()).isEqualTo(permissionSetArn); assertThat(response.permissionSetProvisioningStatus().requestId()).isNotBlank(); + + var described = sso.describePermissionSetProvisioningStatus(request -> request + .instanceArn(instanceArn) + .provisionPermissionSetRequestId(response.permissionSetProvisioningStatus().requestId())); + assertThat(described.permissionSetProvisioningStatus().requestId()) + .isEqualTo(response.permissionSetProvisioningStatus().requestId()); + assertThat(described.permissionSetProvisioningStatus().statusAsString()).isEqualTo("SUCCEEDED"); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 17e3f9ae2f..a564b1dbe6 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -36,6 +36,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | +| `DescribePermissionSetProvisioningStatus` | Describes a persisted permission-set provisioning request by its AWS-compatible UUID request identifier. | | `ListPermissionSetsProvisionedToAccount` | Lists permission sets provisioned to an AWS account, including current/stale provisioning status filters and AWS-compatible pagination. | | `ListAccountsForProvisionedPermissionSet` | Lists AWS accounts where a permission set is provisioned, including current/stale provisioning status filters and AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index f8f6408dd4..08dcdca0f4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -53,6 +53,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListAccountAssignments" -> listAccountAssignments(request); case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "ProvisionPermissionSet" -> provisionPermissionSet(request); + case "DescribePermissionSetProvisioningStatus" -> describePermissionSetProvisioningStatus(request); case "ListPermissionSetsProvisionedToAccount" -> listPermissionSetsProvisionedToAccount(request); case "ListAccountsForProvisionedPermissionSet" -> listAccountsForProvisionedPermissionSet(request); case "CreateAccountAssignment" -> createAccountAssignment(request); @@ -275,6 +276,27 @@ private Response provisionPermissionSet(JsonNode request) { return Response.ok(response).build(); } + private Response describePermissionSetProvisioningStatus(JsonNode request) { + PermissionSetProvisioningOperation operation = service.getPermissionSetProvisioningOperation( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "ProvisionPermissionSetRequestId")); + ObjectNode response = mapper.createObjectNode(); + ObjectNode status = response.putObject("PermissionSetProvisioningStatus"); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + if (operation.accountId() != null) { + status.put("AccountId", operation.accountId()); + } + if (operation.permissionSetArn() != null) { + status.put("PermissionSetArn", operation.permissionSetArn()); + } + if (operation.failureReason() != null) { + status.put("FailureReason", operation.failureReason()); + } + return Response.ok(response).build(); + } + private Response listPermissionSetsProvisionedToAccount(JsonNode request) { var page = service.listPermissionSetsProvisionedToAccount(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 936fb9c045..ff8476478a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -999,6 +999,15 @@ public AssignmentDeletionOperation getAssignmentDeletionOperation(String instanc .orElseThrow(() -> notFound("Assignment deletion operation not found: " + requestId)); } + public PermissionSetProvisioningOperation getPermissionSetProvisioningOperation(String instanceArn, String requestId) { + requireInstance(instanceArn); + if (requestId == null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { + throw validation("ProvisionPermissionSetRequestId must be a UUID."); + } + return permissionSetProvisioningOperations.get(requestId) + .orElseThrow(() -> notFound("Permission set provisioning operation not found: " + requestId)); + } + public AssignmentOperation getAssignmentOperation(String instanceArn, String requestId) { requireInstance(instanceArn); if (requestId == null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 48fc1fe19c..afdf2ab754 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -459,7 +459,7 @@ void provisionPermissionSetReturnsSuccessfulOperationStatus() { .then().statusCode(200) .extract().path("PermissionSet.PermissionSetArn"); - given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + String requestId = given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) .header("X-Amz-Target", "SWBExternalService.ProvisionPermissionSet") .body("{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\",\"TargetType\":\"AWS_ACCOUNT\",\"TargetId\":\"123456789012\"}") @@ -468,7 +468,16 @@ void provisionPermissionSetReturnsSuccessfulOperationStatus() { .body("PermissionSetProvisioningStatus.Status", equalTo("SUCCEEDED")) .body("PermissionSetProvisioningStatus.AccountId", equalTo("123456789012")) .body("PermissionSetProvisioningStatus.PermissionSetArn", equalTo(permissionSetArn)) - .body("PermissionSetProvisioningStatus.RequestId", matchesPattern("[0-9a-f-]{36}")); + .body("PermissionSetProvisioningStatus.RequestId", matchesPattern("[0-9a-f-]{36}")) + .extract().path("PermissionSetProvisioningStatus.RequestId"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribePermissionSetProvisioningStatus") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"ProvisionPermissionSetRequestId\":\"" + requestId + "\"}") + .when().post("/") + .then().statusCode(200) + .body("PermissionSetProvisioningStatus.RequestId", equalTo(requestId)) + .body("PermissionSetProvisioningStatus.Status", equalTo("SUCCEEDED")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index dfe38229d3..87fc6de44a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -647,6 +647,9 @@ void provisionPermissionSetSupportsSingleAndAllProvisionedAccounts() { assertEquals(ACCOUNT_ID, operation.accountId()); assertEquals(permissionSet.arn(), operation.permissionSetArn()); assertTrue(operation.createdDateEpochMillis() > 0); + assertEquals(operation, service.getPermissionSetProvisioningOperation(service.getInstanceArn(), operation.requestId())); + assertError("ValidationException", + () -> service.getPermissionSetProvisioningOperation(service.getInstanceArn(), "not-a-uuid")); ObjectNode assignment = assignmentRequest(permissionSet.arn()); service.createAssignment(assignment); From 4219f8f7194b33171752a2a81d34467bddacef41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:37:23 -0400 Subject: [PATCH 20/99] feat(ssoadmin): list permission set provisioning status --- .../test/SsoAdminAccountAssignmentTest.java | 6 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 17 +++++++++++++ .../services/ssoadmin/SsoAdminService.java | 25 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 7 ++++++ .../ssoadmin/SsoAdminServiceTest.java | 10 ++++++++ 6 files changed, 66 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 11545480e4..a557872d0e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -448,6 +448,12 @@ void provisionPermissionSetUsesAwsSdk() { assertThat(described.permissionSetProvisioningStatus().requestId()) .isEqualTo(response.permissionSetProvisioningStatus().requestId()); assertThat(described.permissionSetProvisioningStatus().statusAsString()).isEqualTo("SUCCEEDED"); + + var listed = sso.listPermissionSetProvisioningStatus(request -> request + .instanceArn(instanceArn) + .filter(filter -> filter.status("SUCCEEDED"))); + assertThat(listed.permissionSetsProvisioningStatus()) + .anyMatch(status -> response.permissionSetProvisioningStatus().requestId().equals(status.requestId())); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index a564b1dbe6..ad114a1450 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -37,6 +37,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | | `DescribePermissionSetProvisioningStatus` | Describes a persisted permission-set provisioning request by its AWS-compatible UUID request identifier. | +| `ListPermissionSetProvisioningStatus` | Lists permission-set provisioning request metadata with optional operation-status filtering and AWS-compatible pagination. | | `ListPermissionSetsProvisionedToAccount` | Lists permission sets provisioned to an AWS account, including current/stale provisioning status filters and AWS-compatible pagination. | | `ListAccountsForProvisionedPermissionSet` | Lists AWS accounts where a permission set is provisioned, including current/stale provisioning status filters and AWS-compatible pagination. | | `CreateAccountAssignment` | Creates an account assignment and operation record. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 08dcdca0f4..8b46096646 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -54,6 +54,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "ProvisionPermissionSet" -> provisionPermissionSet(request); case "DescribePermissionSetProvisioningStatus" -> describePermissionSetProvisioningStatus(request); + case "ListPermissionSetProvisioningStatus" -> listPermissionSetProvisioningStatus(request); case "ListPermissionSetsProvisionedToAccount" -> listPermissionSetsProvisionedToAccount(request); case "ListAccountsForProvisionedPermissionSet" -> listAccountsForProvisionedPermissionSet(request); case "CreateAccountAssignment" -> createAccountAssignment(request); @@ -297,6 +298,22 @@ private Response describePermissionSetProvisioningStatus(JsonNode request) { return Response.ok(response).build(); } + private Response listPermissionSetProvisioningStatus(JsonNode request) { + var page = service.listPermissionSetProvisioningStatus(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode statuses = response.putArray("PermissionSetsProvisioningStatus"); + for (PermissionSetProvisioningOperation operation : page.items()) { + ObjectNode status = statuses.addObject(); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response listPermissionSetsProvisionedToAccount(JsonNode request) { var page = service.listPermissionSetsProvisionedToAccount(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ff8476478a..b43633d310 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1008,6 +1008,31 @@ public PermissionSetProvisioningOperation getPermissionSetProvisioningOperation( .orElseThrow(() -> notFound("Permission set provisioning operation not found: " + requestId)); } + public PaginatedResult listPermissionSetProvisioningStatus(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Permission sets are only available from an organization instance."); + } + String statusFilter = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() > 1 || !filter.has("Status")) { + throw validation("Filter may contain only Status."); + } + statusFilter = required(filter, "Status"); + if (!Set.of("IN_PROGRESS", "FAILED", "SUCCEEDED").contains(statusFilter)) { + throw validation("Filter.Status is invalid."); + } + } + String finalStatusFilter = statusFilter; + List operations = permissionSetProvisioningOperations.scan(key -> true).stream() + .filter(operation -> finalStatusFilter == null || finalStatusFilter.equals(operation.status())) + .toList(); + return Pagination.paginate(operations, PermissionSetProvisioningOperation::requestId, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + public AssignmentOperation getAssignmentOperation(String instanceArn, String requestId) { requireInstance(instanceArn); if (requestId == null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index afdf2ab754..26b222298c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -478,6 +478,13 @@ void provisionPermissionSetReturnsSuccessfulOperationStatus() { .then().statusCode(200) .body("PermissionSetProvisioningStatus.RequestId", equalTo(requestId)) .body("PermissionSetProvisioningStatus.Status", equalTo("SUCCEEDED")); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListPermissionSetProvisioningStatus") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Filter\":{\"Status\":\"SUCCEEDED\"}}") + .when().post("/") + .then().statusCode(200) + .body("PermissionSetsProvisioningStatus.RequestId", org.hamcrest.Matchers.hasItem(requestId)); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 87fc6de44a..4cafff05d7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -661,6 +661,16 @@ void provisionPermissionSetSupportsSingleAndAllProvisionedAccounts() { assertEquals("SUCCEEDED", allOperation.status()); assertTrue(allOperation.accountId() == null); + ObjectNode listStatus = mapper.createObjectNode(); + listStatus.put("InstanceArn", service.getInstanceArn()); + listStatus.put("MaxResults", 1); + listStatus.putObject("Filter").put("Status", "SUCCEEDED"); + var statusPage = service.listPermissionSetProvisioningStatus(listStatus); + assertEquals(1, statusPage.items().size()); + assertNotNull(statusPage.nextToken()); + listStatus.putObject("Filter").put("Status", "INVALID"); + assertError("ValidationException", () -> service.listPermissionSetProvisioningStatus(listStatus)); + ObjectNode invalid = single.deepCopy(); invalid.put("TargetType", "ORGANIZATION"); assertError("ValidationException", () -> service.provisionPermissionSet(invalid)); From 4866b7123a3fc3cd132bc797615a135b33b772d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 10:41:00 -0400 Subject: [PATCH 21/99] feat(ssoadmin): describe account assignment deletion status --- .../test/SsoAdminAccountAssignmentTest.java | 6 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 21 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 13 ++++++++++-- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index a557872d0e..735314484b 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -537,6 +537,12 @@ void deleteAccountAssignmentUsesAwsSdk() { assertThat(deleted.accountAssignmentDeletionStatus()).isNotNull(); assertThat(deleted.accountAssignmentDeletionStatus().statusAsString()).isEqualTo("SUCCEEDED"); assertThat(deleted.accountAssignmentDeletionStatus().requestId()).isNotBlank(); + var describedDeletion = sso.describeAccountAssignmentDeletionStatus(request -> request + .instanceArn(instanceArn) + .accountAssignmentDeletionRequestId(deleted.accountAssignmentDeletionStatus().requestId())); + assertThat(describedDeletion.accountAssignmentDeletionStatus().requestId()) + .isEqualTo(deleted.accountAssignmentDeletionStatus().requestId()); + assertThat(describedDeletion.accountAssignmentDeletionStatus().statusAsString()).isEqualTo("SUCCEEDED"); assertThat(sso.listAccountAssignments(request -> request .instanceArn(instanceArn) .accountId("123456789012") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index ad114a1450..3936aebc14 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -43,6 +43,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | +| `DescribeAccountAssignmentDeletionStatus` | Describes a persisted account-assignment deletion request by its AWS-compatible UUID request identifier. | State is isolated by caller account through Floci storage. diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 8b46096646..9faeef9ea2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -60,6 +60,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); + case "DescribeAccountAssignmentDeletionStatus" -> describeAssignmentDeletion(request); default -> throw new AwsException("UnknownOperationException", "Operation " + action + " is not supported.", 400); }; } @@ -370,6 +371,26 @@ private Response describeAssignment(JsonNode request) { return Response.ok(response).build(); } + private Response describeAssignmentDeletion(JsonNode request) { + var operation = service.getAssignmentDeletionOperation( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "AccountAssignmentDeletionRequestId")); + ObjectNode response = mapper.createObjectNode(); + ObjectNode status = response.putObject("AccountAssignmentDeletionStatus"); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + status.put("TargetId", operation.accountId()); + status.put("TargetType", "AWS_ACCOUNT"); + status.put("PermissionSetArn", operation.permissionSetArn()); + status.put("PrincipalId", operation.principalId()); + status.put("PrincipalType", operation.principalType()); + if (operation.failureReason() != null) { + status.put("FailureReason", operation.failureReason()); + } + return Response.ok(response).build(); + } + private ObjectNode permissionSetNode(PermissionSet p) { ObjectNode node = mapper.createObjectNode(); node.put("PermissionSetArn", p.arn()); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 26b222298c..6357f81757 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -558,7 +558,7 @@ void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { .header("X-Amz-Target", "SWBExternalService.CreateAccountAssignment") .body(assignmentRequest).when().post("/").then().statusCode(200); - given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + String deletionRequestId = given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) .header("X-Amz-Target", "SWBExternalService.DeleteAccountAssignment") .body(assignmentRequest) .when().post("/") @@ -566,7 +566,16 @@ void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { .statusCode(200) .body("AccountAssignmentDeletionStatus.Status", equalTo("SUCCEEDED")) .body("AccountAssignmentDeletionStatus.RequestId", matchesPattern("[0-9a-f-]{36}")) - .body("AccountAssignmentDeletionStatus.CreatedDate", org.hamcrest.Matchers.greaterThan(0.0f)); + .body("AccountAssignmentDeletionStatus.CreatedDate", org.hamcrest.Matchers.greaterThan(0.0f)) + .extract().path("AccountAssignmentDeletionStatus.RequestId"); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeAccountAssignmentDeletionStatus") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"AccountAssignmentDeletionRequestId\":\"" + deletionRequestId + "\"}") + .when().post("/") + .then().statusCode(200) + .body("AccountAssignmentDeletionStatus.RequestId", equalTo(deletionRequestId)) + .body("AccountAssignmentDeletionStatus.Status", equalTo("SUCCEEDED")); } @Test From c4c3292811b29c58e47d3c613af870da271a205f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 11:43:22 -0400 Subject: [PATCH 22/99] feat(ssoadmin): list account assignment creation status --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 18 +++++++++++ .../services/ssoadmin/SsoAdminService.java | 31 +++++++++++++++++-- .../ssoadmin/model/AssignmentOperation.java | 3 +- .../SsoAdminAssignmentsIntegrationTest.java | 8 ++++- .../ssoadmin/SsoAdminServiceTest.java | 12 +++++++ 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 735314484b..d89ec556d7 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -578,6 +578,13 @@ void accountAssignmentLifecycleUsesAwsSdk() { .instanceArn(instanceArn) .accountAssignmentCreationRequestId(created.accountAssignmentCreationStatus().requestId())); assertThat(status.accountAssignmentCreationStatus().statusAsString()).isEqualTo("SUCCEEDED"); + assertThat(status.accountAssignmentCreationStatus().createdDate()).isNotNull(); + + var creationStatuses = sso.listAccountAssignmentCreationStatus(request -> request + .instanceArn(instanceArn) + .filter(filter -> filter.status("SUCCEEDED"))); + assertThat(creationStatuses.accountAssignmentsCreationStatus()) + .anyMatch(operation -> created.accountAssignmentCreationStatus().requestId().equals(operation.requestId())); var assignments = sso.listAccountAssignments(request -> request .instanceArn(instanceArn) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 3936aebc14..7c1072da11 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -43,6 +43,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateAccountAssignment` | Creates an account assignment and operation record. | | `DeleteAccountAssignment` | Deletes an account assignment and returns a persisted deletion operation status. | | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | +| `ListAccountAssignmentCreationStatus` | Lists account-assignment creation request metadata with optional operation-status filtering and AWS-compatible pagination. | | `DescribeAccountAssignmentDeletionStatus` | Describes a persisted account-assignment deletion request by its AWS-compatible UUID request identifier. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 9faeef9ea2..831a01414c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -60,6 +60,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateAccountAssignment" -> createAccountAssignment(request); case "DeleteAccountAssignment" -> deleteAccountAssignment(request); case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); + case "ListAccountAssignmentCreationStatus" -> listAccountAssignmentCreationStatus(request); case "DescribeAccountAssignmentDeletionStatus" -> describeAssignmentDeletion(request); default -> throw new AwsException("UnknownOperationException", "Operation " + action + " is not supported.", 400); }; @@ -371,6 +372,22 @@ private Response describeAssignment(JsonNode request) { return Response.ok(response).build(); } + private Response listAccountAssignmentCreationStatus(JsonNode request) { + var page = service.listAccountAssignmentCreationStatus(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode statuses = response.putArray("AccountAssignmentsCreationStatus"); + for (AssignmentOperation operation : page.items()) { + ObjectNode status = statuses.addObject(); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + } + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response describeAssignmentDeletion(JsonNode request) { var operation = service.getAssignmentDeletionOperation( SsoAdminService.required(request, "InstanceArn"), @@ -405,6 +422,7 @@ private ObjectNode permissionSetNode(PermissionSet p) { private ObjectNode assignmentOperationNode(AssignmentOperation op) { ObjectNode node = mapper.createObjectNode(); node.put("RequestId", op.requestId()); node.put("Status", op.status()); + node.put("CreatedDate", op.createdDateEpochMillis() / 1000.0d); node.put("TargetId", op.accountId()); node.put("TargetType", "AWS_ACCOUNT"); node.put("PermissionSetArn", op.permissionSetArn()); node.put("PrincipalId", op.principalId()); node.put("PrincipalType", op.principalType()); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index b43633d310..f8ad84b743 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -956,8 +956,8 @@ public synchronized AssignmentOperation createAssignment(JsonNode request) { assignments.put(key, assignment); ensurePermissionSetProvisioned(permission, account); String requestId = UUID.randomUUID().toString(); - AssignmentOperation operation = new AssignmentOperation(requestId, "SUCCEEDED", account, permission, - principal, principalType, null); + AssignmentOperation operation = new AssignmentOperation(requestId, "SUCCEEDED", System.currentTimeMillis(), + account, permission, principal, principalType, null); assignmentOperations.put(requestId, operation); return operation; } @@ -1041,6 +1041,33 @@ public AssignmentOperation getAssignmentOperation(String instanceArn, String req return assignmentOperations.get(requestId).orElseThrow(() -> notFound("Assignment operation not found: " + requestId)); } + public PaginatedResult listAccountAssignmentCreationStatus(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Account assignments are only available from an organization instance."); + } + String statusFilter = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() > 1 || (filter.size() == 1 && !filter.has("Status"))) { + throw validation("Filter may contain only Status."); + } + if (filter.has("Status")) { + statusFilter = required(filter, "Status"); + if (!Set.of("IN_PROGRESS", "FAILED", "SUCCEEDED").contains(statusFilter)) { + throw validation("Filter.Status is invalid."); + } + } + } + String finalStatusFilter = statusFilter; + List operations = assignmentOperations.scan(key -> true).stream() + .filter(operation -> finalStatusFilter == null || finalStatusFilter.equals(operation.status())) + .toList(); + return Pagination.paginate(operations, AssignmentOperation::requestId, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + static String required(JsonNode request, String field) { String value = text(request, field); if (value == null || value.isBlank()) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentOperation.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentOperation.java index 2e552d0894..2773bf76d1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentOperation.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/AssignmentOperation.java @@ -3,5 +3,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection; @RegisterForReflection -public record AssignmentOperation(String requestId, String status, String accountId, String permissionSetArn, +public record AssignmentOperation(String requestId, String status, long createdDateEpochMillis, + String accountId, String permissionSetArn, String principalId, String principalType, String failureReason) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index 17b3780439..4b9fa5f98c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -38,7 +38,13 @@ void permissionSetAndAccountAssignmentLifecycle() { .then().statusCode(200).extract().path("AccountAssignmentCreationStatus.RequestId"); json("SWBExternalService.DescribeAccountAssignmentCreationStatus", "{\"InstanceArn\":\"" + instanceArn + "\",\"AccountAssignmentCreationRequestId\":\"" + requestId + "\"}") - .then().statusCode(200).body("AccountAssignmentCreationStatus.Status", equalTo("SUCCEEDED")); + .then().statusCode(200) + .body("AccountAssignmentCreationStatus.Status", equalTo("SUCCEEDED")) + .body("AccountAssignmentCreationStatus.CreatedDate", notNullValue()); + json("SWBExternalService.ListAccountAssignmentCreationStatus", + "{\"InstanceArn\":\"" + instanceArn + "\",\"Filter\":{\"Status\":\"SUCCEEDED\"}}") + .then().statusCode(200) + .body("AccountAssignmentsCreationStatus.RequestId", org.hamcrest.Matchers.hasItem(requestId)); json("SWBExternalService.ListAccountAssignments", "{\"InstanceArn\":\"" + instanceArn + "\",\"AccountId\":\"123456789012\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 4cafff05d7..8cd72ce7a5 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -771,6 +771,18 @@ void assignmentValidationAndDuplicateDetectionAreModeled() { ObjectNode request = assignmentRequest(permissionSet.arn()); AssignmentOperation created = service.createAssignment(request); assertEquals("SUCCEEDED", created.status()); + assertTrue(created.createdDateEpochMillis() > 0); + + ObjectNode listStatus = mapper.createObjectNode(); + listStatus.put("InstanceArn", service.getInstanceArn()); + listStatus.putObject("Filter").put("Status", "SUCCEEDED"); + var statusPage = service.listAccountAssignmentCreationStatus(listStatus); + assertEquals(1, statusPage.items().size()); + assertEquals(created.requestId(), statusPage.items().get(0).requestId()); + listStatus.putObject("Filter"); + assertEquals(1, service.listAccountAssignmentCreationStatus(listStatus).items().size()); + listStatus.putObject("Filter").put("Status", "INVALID"); + assertError("ValidationException", () -> service.listAccountAssignmentCreationStatus(listStatus)); assertError("ConflictException", () -> service.createAssignment(request)); From cd1179de5f47213c4408a50d3f1011b601a76c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 11:49:44 -0400 Subject: [PATCH 23/99] feat(ssoadmin): list account assignment deletion status --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 17 ++++++++++++ .../services/ssoadmin/SsoAdminService.java | 27 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 7 +++++ .../ssoadmin/SsoAdminServiceTest.java | 10 +++++++ 6 files changed, 69 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index d89ec556d7..c8fc629d1d 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -543,6 +543,13 @@ void deleteAccountAssignmentUsesAwsSdk() { assertThat(describedDeletion.accountAssignmentDeletionStatus().requestId()) .isEqualTo(deleted.accountAssignmentDeletionStatus().requestId()); assertThat(describedDeletion.accountAssignmentDeletionStatus().statusAsString()).isEqualTo("SUCCEEDED"); + + var deletionStatuses = sso.listAccountAssignmentDeletionStatus(request -> request + .instanceArn(instanceArn) + .filter(filter -> filter.status("SUCCEEDED"))); + assertThat(deletionStatuses.accountAssignmentsDeletionStatus()) + .anyMatch(operation -> deleted.accountAssignmentDeletionStatus().requestId().equals(operation.requestId())); + assertThat(sso.listAccountAssignments(request -> request .instanceArn(instanceArn) .accountId("123456789012") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 7c1072da11..5a6d6e257b 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -45,6 +45,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeAccountAssignmentCreationStatus` | Describes account-assignment creation status. | | `ListAccountAssignmentCreationStatus` | Lists account-assignment creation request metadata with optional operation-status filtering and AWS-compatible pagination. | | `DescribeAccountAssignmentDeletionStatus` | Describes a persisted account-assignment deletion request by its AWS-compatible UUID request identifier. | +| `ListAccountAssignmentDeletionStatus` | Lists account-assignment deletion request metadata with optional operation-status filtering and AWS-compatible pagination. | State is isolated by caller account through Floci storage. diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 831a01414c..cf72701249 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -62,6 +62,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeAccountAssignmentCreationStatus" -> describeAssignment(request); case "ListAccountAssignmentCreationStatus" -> listAccountAssignmentCreationStatus(request); case "DescribeAccountAssignmentDeletionStatus" -> describeAssignmentDeletion(request); + case "ListAccountAssignmentDeletionStatus" -> listAccountAssignmentDeletionStatus(request); default -> throw new AwsException("UnknownOperationException", "Operation " + action + " is not supported.", 400); }; } @@ -408,6 +409,22 @@ private Response describeAssignmentDeletion(JsonNode request) { return Response.ok(response).build(); } + private Response listAccountAssignmentDeletionStatus(JsonNode request) { + var page = service.listAccountAssignmentDeletionStatus(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode statuses = response.putArray("AccountAssignmentsDeletionStatus"); + page.items().forEach(operation -> { + ObjectNode status = statuses.addObject(); + status.put("RequestId", operation.requestId()); + status.put("Status", operation.status()); + status.put("CreatedDate", operation.createdDateEpochMillis() / 1000.0d); + }); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private ObjectNode permissionSetNode(PermissionSet p) { ObjectNode node = mapper.createObjectNode(); node.put("PermissionSetArn", p.arn()); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f8ad84b743..250bdb354e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1068,6 +1068,33 @@ public PaginatedResult listAccountAssignmentCreationStatus( optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + public PaginatedResult listAccountAssignmentDeletionStatus(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Account assignments are only available from an organization instance."); + } + String statusFilter = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() > 1 || (filter.size() == 1 && !filter.has("Status"))) { + throw validation("Filter may contain only Status."); + } + if (filter.has("Status")) { + statusFilter = required(filter, "Status"); + if (!Set.of("IN_PROGRESS", "FAILED", "SUCCEEDED").contains(statusFilter)) { + throw validation("Filter.Status is invalid."); + } + } + } + String finalStatusFilter = statusFilter; + List operations = assignmentDeletionOperations.scan(key -> true).stream() + .filter(operation -> finalStatusFilter == null || finalStatusFilter.equals(operation.status())) + .toList(); + return Pagination.paginate(operations, AssignmentDeletionOperation::requestId, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + static String required(JsonNode request, String field) { String value = text(request, field); if (value == null || value.isBlank()) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 6357f81757..c2640b143c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -576,6 +576,13 @@ void deleteAccountAssignmentReturnsDeletionOperationAndRemovesAssignment() { .then().statusCode(200) .body("AccountAssignmentDeletionStatus.RequestId", equalTo(deletionRequestId)) .body("AccountAssignmentDeletionStatus.Status", equalTo("SUCCEEDED")); + + given().contentType("application/x-amz-json-1.1").header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListAccountAssignmentDeletionStatus") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Filter\":{\"Status\":\"SUCCEEDED\"}}") + .when().post("/") + .then().statusCode(200) + .body("AccountAssignmentsDeletionStatus.RequestId", org.hamcrest.Matchers.hasItem(deletionRequestId)); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 8cd72ce7a5..9e0e6ac13b 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -803,6 +803,16 @@ void deleteAccountAssignmentRemovesAssignmentAndCreatesDeletionOperation() { assertTrue(deleted.createdDateEpochMillis() > 0); assertTrue(service.listAssignments(service.getInstanceArn(), ACCOUNT_ID, permissionSet.arn()).isEmpty()); assertEquals(deleted, service.getAssignmentDeletionOperation(service.getInstanceArn(), deleted.requestId())); + + ObjectNode listStatus = mapper.createObjectNode(); + listStatus.put("InstanceArn", service.getInstanceArn()); + listStatus.putObject("Filter").put("Status", "SUCCEEDED"); + var statusPage = service.listAccountAssignmentDeletionStatus(listStatus); + assertEquals(1, statusPage.items().size()); + assertEquals(deleted.requestId(), statusPage.items().get(0).requestId()); + listStatus.putObject("Filter").put("Status", "INVALID"); + assertError("ValidationException", () -> service.listAccountAssignmentDeletionStatus(listStatus)); + assertError("ResourceNotFoundException", () -> service.deleteAssignment(request)); } From cebd59713cb7b4e8c25f974bb23d46a4c2ad0023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 11:55:14 -0400 Subject: [PATCH 24/99] feat(ssoadmin): delete permission sets --- .../test/SsoAdminAccountAssignmentTest.java | 22 +++++++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 8 +++++++ .../services/ssoadmin/SsoAdminService.java | 21 ++++++++++++++++++ .../SsoAdminAssignmentsIntegrationTest.java | 16 ++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 17 ++++++++++++++ 6 files changed, 85 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c8fc629d1d..66f70d7739 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -507,6 +507,28 @@ void listAccountsForProvisionedPermissionSetUsesAwsSdk() { } } + @Test + @DisplayName("deletes permission sets through the AWS SDK") + void deletePermissionSetUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only permission-set lifecycle"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn) + .name("DeletePermissionSetSdkAdmins")) + .permissionSet().permissionSetArn(); + var response = sso.deletePermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn)); + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.describePermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + } + } + @Test @DisplayName("deletes account assignments through the AWS SDK") void deleteAccountAssignmentUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 5a6d6e257b..d397776b04 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -25,6 +25,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | +| `DeletePermissionSet` | Deletes a permission set and removes its local account-assignment and provisioning state. | | `DescribePermissionSet` | Describes a permission set. | | `UpdatePermissionSet` | Updates mutable permission-set settings. | | `ListManagedPoliciesInPermissionSet` | Lists attached AWS managed policies with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index cf72701249..033f64039d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -42,6 +42,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); + case "DeletePermissionSet" -> deletePermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); case "UpdatePermissionSet" -> updatePermissionSet(request); case "ListManagedPoliciesInPermissionSet" -> listManagedPolicies(request); @@ -179,6 +180,13 @@ private Response createPermissionSet(JsonNode request) { return Response.ok(response).build(); } + private Response deletePermissionSet(JsonNode request) { + service.deletePermissionSet( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "PermissionSetArn")); + return Response.ok().build(); + } + private Response describePermissionSet(JsonNode request) { PermissionSet p = service.getPermissionSet( SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn")); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 250bdb354e..0ec9dbcb1d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -702,6 +702,27 @@ public PermissionSet getPermissionSet(String instanceArn, String arn) { return permissionSets.get(arn).orElseThrow(() -> notFound("Permission set not found: " + arn)); } + public synchronized void deletePermissionSet(String instanceArn, String permissionSetArn) { + SsoInstance instance = requireInstance(instanceArn); + if (instance.accountInstance()) { + throw accessDenied("Permission sets are only available from an organization instance."); + } + getPermissionSet(instanceArn, permissionSetArn); + permissionSets.delete(permissionSetArn); + for (String key : new ArrayList<>(assignments.keys())) { + Assignment assignment = assignments.get(key).orElse(null); + if (assignment != null && permissionSetArn.equals(assignment.permissionSetArn())) { + assignments.delete(key); + } + } + for (String key : new ArrayList<>(permissionSetProvisionings.keys())) { + PermissionSetProvisioning provisioning = permissionSetProvisionings.get(key).orElse(null); + if (provisioning != null && permissionSetArn.equals(provisioning.permissionSetArn())) { + permissionSetProvisionings.delete(key); + } + } + } + public synchronized PermissionSet updatePermissionSet(JsonNode request) { PermissionSet current = getPermissionSet(required(request, "InstanceArn"), required(request, "PermissionSetArn")); String description = request.has("Description") ? optionalDescription(request) : current.description(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index 4b9fa5f98c..c4820ed189 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -51,6 +51,22 @@ void permissionSetAndAccountAssignmentLifecycle() { .then().statusCode(200).body("AccountAssignments[0].PrincipalType", equalTo("GROUP")); } + @Test + void deletePermissionSetReturnsEmptyResponseAndRemovesIt() { + String instanceArn = json("SWBExternalService.ListInstances", "{}") + .then().statusCode(200).extract().path("Instances[0].InstanceArn"); + String permissionSetArn = json("SWBExternalService.CreatePermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"DeletePermissionSetIntegration\"}") + .then().statusCode(200).extract().path("PermissionSet.PermissionSetArn"); + + json("SWBExternalService.DeletePermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") + .then().statusCode(200).body(equalTo("")); + json("SWBExternalService.DescribePermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") + .then().statusCode(400).body("__type", equalTo("ResourceNotFoundException")); + } + @Test void invalidAssignmentRequestIdReturnsResourceNotFound() { String instanceArn = json("SWBExternalService.ListInstances", "{}") diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 9e0e6ac13b..612f266976 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -592,6 +592,23 @@ void duplicateManagedPolicyReturnsConflict() { () -> service.attachPolicy(service.getInstanceArn(), permissionSet.arn(), policyArn)); } + @Test + void deletePermissionSetRemovesAssignmentsAndProvisioningState() { + PermissionSet permissionSet = createPermissionSet("DeletePermissionSetAdmins"); + service.createAssignment(assignmentRequest(permissionSet.arn())); + assertFalse(service.listAssignments(service.getInstanceArn(), ACCOUNT_ID, permissionSet.arn()).isEmpty()); + + service.deletePermissionSet(service.getInstanceArn(), permissionSet.arn()); + + assertError("ResourceNotFoundException", + () -> service.getPermissionSet(service.getInstanceArn(), permissionSet.arn())); + assertTrue(service.listPermissionSetsProvisionedToAccount(mapper.createObjectNode() + .put("InstanceArn", service.getInstanceArn()) + .put("AccountId", ACCOUNT_ID)).items().stream().noneMatch(permissionSet.arn()::equals)); + assertError("ResourceNotFoundException", + () -> service.deletePermissionSet(service.getInstanceArn(), permissionSet.arn())); + } + @Test void listAccountAssignmentsForPrincipalFiltersAndPaginatesUserOrGroupAccess() { PermissionSet first = createPermissionSet("PrincipalListOne"); From a95649ff74973575be008c51cbc67a64f0e657a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:00:57 -0400 Subject: [PATCH 25/99] feat(ssoadmin): detach customer managed policy references --- .../test/SsoAdminAccountAssignmentTest.java | 15 ++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 24 +++++++++++++++++++ .../SsoAdminAssignmentsIntegrationTest.java | 15 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 6 +++++ 6 files changed, 67 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 66f70d7739..0734eac984 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -61,6 +61,21 @@ void customerManagedPolicyReferenceUsesAwsSdk() { .name("platformpolicy") .path("/platform/")))) .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + + var detached = sso.detachCustomerManagedPolicyReferenceFromPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .customerManagedPolicyReference(reference -> reference + .name("platformpolicy") + .path("/platform/"))); + assertThat(detached.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.detachCustomerManagedPolicyReferenceFromPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .customerManagedPolicyReference(reference -> reference + .name("PlatformPolicy") + .path("/platform/")))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d397776b04..43169f6eb5 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -31,6 +31,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListManagedPoliciesInPermissionSet` | Lists attached AWS managed policies with AWS-compatible pagination. | | `AttachManagedPolicyToPermissionSet` | Attaches an AWS managed policy. | | `AttachCustomerManagedPolicyReferenceToPermissionSet` | Attaches a customer managed IAM policy reference by name and path. | +| `DetachCustomerManagedPolicyReferenceFromPermissionSet` | Detaches a customer managed IAM policy reference from a permission set by name and path. | | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 033f64039d..5b2a66e5f7 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -48,6 +48,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListManagedPoliciesInPermissionSet" -> listManagedPolicies(request); case "AttachManagedPolicyToPermissionSet" -> attachManagedPolicy(request); case "AttachCustomerManagedPolicyReferenceToPermissionSet" -> attachCustomerManagedPolicyReference(request); + case "DetachCustomerManagedPolicyReferenceFromPermissionSet" -> detachCustomerManagedPolicyReference(request); case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); @@ -223,6 +224,11 @@ private Response attachCustomerManagedPolicyReference(JsonNode request) { return Response.ok().build(); } + private Response detachCustomerManagedPolicyReference(JsonNode request) { + service.detachCustomerManagedPolicyReference(request); + return Response.ok().build(); + } + private Response detachManagedPolicy(JsonNode request) { service.detachPolicy(SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn"), SsoAdminService.required(request, "ManagedPolicyArn")); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 0ec9dbcb1d..c105f175c9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -778,6 +778,30 @@ public synchronized void attachCustomerManagedPolicyReference(JsonNode request) markPermissionSetProvisioningStale(current.arn()); } + public synchronized void detachCustomerManagedPolicyReference(JsonNode request) { + PermissionSet current = getPermissionSet(required(request, "InstanceArn"), required(request, "PermissionSetArn")); + JsonNode reference = request.get("CustomerManagedPolicyReference"); + if (reference == null || !reference.isObject()) { + throw validation("CustomerManagedPolicyReference must be an object."); + } + String name = required(reference, "Name"); + if (name.length() > 128 || !CUSTOMER_MANAGED_POLICY_NAME.matcher(name).matches()) { + throw validation("CustomerManagedPolicyReference.Name is invalid."); + } + String path = text(reference, "Path"); + if (path == null) { + path = "/"; + } else if (path.length() > 512 || !CUSTOMER_MANAGED_POLICY_PATH.matcher(path).matches()) { + throw validation("CustomerManagedPolicyReference.Path is invalid."); + } + String key = customerManagedPolicyKey(name, path); + if (current.customerManagedPolicies().remove(key) == null) { + throw notFound("Customer managed policy reference is not attached to the permission set."); + } + permissionSets.put(current.arn(), current); + markPermissionSetProvisioningStale(current.arn()); + } + public synchronized void detachPolicy(String instanceArn, String arn, String policyArn) { PermissionSet current = getPermissionSet(instanceArn, arn); validateManagedPolicyArn(policyArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index c4820ed189..b62a76f541 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -67,6 +67,21 @@ void deletePermissionSetReturnsEmptyResponseAndRemovesIt() { .then().statusCode(400).body("__type", equalTo("ResourceNotFoundException")); } + @Test + void detachCustomerManagedPolicyReferenceReturnsEmptyResponse() { + String instanceArn = json("SWBExternalService.ListInstances", "{}") + .then().statusCode(200).extract().path("Instances[0].InstanceArn"); + String permissionSetArn = json("SWBExternalService.CreatePermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"DetachCustomerPolicyIntegration\"}") + .then().statusCode(200).extract().path("PermissionSet.PermissionSetArn"); + String body = "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"CustomerManagedPolicyReference\":{\"Name\":\"PlatformPolicy\",\"Path\":\"/platform/\"}}"; + json("SWBExternalService.AttachCustomerManagedPolicyReferenceToPermissionSet", body) + .then().statusCode(200); + json("SWBExternalService.DetachCustomerManagedPolicyReferenceFromPermissionSet", body) + .then().statusCode(200).body(equalTo("")); + } + @Test void invalidAssignmentRequestIdReturnsResourceNotFound() { String instanceArn = json("SWBExternalService.ListInstances", "{}") diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 612f266976..d49a773619 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -526,6 +526,12 @@ void attachesCustomerManagedPolicyReferencesWithAwsValidationRules() { ObjectNode invalidPath = request.deepCopy(); invalidPath.withObject("CustomerManagedPolicyReference").put("Name", "OtherPolicy").put("Path", "missing-slash"); assertError("ValidationException", () -> service.attachCustomerManagedPolicyReference(invalidPath)); + + ObjectNode detach = request.deepCopy(); + detach.withObject("CustomerManagedPolicyReference").put("Name", "platformpolicy"); + service.detachCustomerManagedPolicyReference(detach); + assertTrue(service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()).customerManagedPolicies().isEmpty()); + assertError("ResourceNotFoundException", () -> service.detachCustomerManagedPolicyReference(detach)); } @Test From f4999e772142f4089a01e4eb333bc7ad6c493867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:04:53 -0400 Subject: [PATCH 26/99] feat(ssoadmin): list customer managed policy references --- .../floci/test/SsoAdminAccountAssignmentTest.java | 8 ++++++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 14 ++++++++++++++ .../floci/services/ssoadmin/SsoAdminService.java | 11 +++++++++++ .../SsoAdminAssignmentsIntegrationTest.java | 5 +++++ .../services/ssoadmin/SsoAdminServiceTest.java | 6 ++++++ 6 files changed, 45 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 0734eac984..32229b7a73 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -54,6 +54,14 @@ void customerManagedPolicyReferenceUsesAwsSdk() { .path("/platform/"))); assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + var references = sso.listCustomerManagedPolicyReferencesInPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn)); + assertThat(references.customerManagedPolicyReferences()) + .anySatisfy(reference -> { + assertThat(reference.name()).isEqualTo("PlatformPolicy"); + assertThat(reference.path()).isEqualTo("/platform/"); + }); assertThatThrownBy(() -> sso.attachCustomerManagedPolicyReferenceToPermissionSet(request -> request .instanceArn(instanceArn) .permissionSetArn(permissionSetArn) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 43169f6eb5..18346ff42d 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -32,6 +32,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `AttachManagedPolicyToPermissionSet` | Attaches an AWS managed policy. | | `AttachCustomerManagedPolicyReferenceToPermissionSet` | Attaches a customer managed IAM policy reference by name and path. | | `DetachCustomerManagedPolicyReferenceFromPermissionSet` | Detaches a customer managed IAM policy reference from a permission set by name and path. | +| `ListCustomerManagedPolicyReferencesInPermissionSet` | Lists customer managed IAM policy references attached to a permission set with AWS-compatible pagination. | | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 5b2a66e5f7..3c38f953ac 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -49,6 +49,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "AttachManagedPolicyToPermissionSet" -> attachManagedPolicy(request); case "AttachCustomerManagedPolicyReferenceToPermissionSet" -> attachCustomerManagedPolicyReference(request); case "DetachCustomerManagedPolicyReferenceFromPermissionSet" -> detachCustomerManagedPolicyReference(request); + case "ListCustomerManagedPolicyReferencesInPermissionSet" -> listCustomerManagedPolicyReferences(request); case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); @@ -229,6 +230,19 @@ private Response detachCustomerManagedPolicyReference(JsonNode request) { return Response.ok().build(); } + private Response listCustomerManagedPolicyReferences(JsonNode request) { + var page = service.listCustomerManagedPolicyReferences(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode references = response.putArray("CustomerManagedPolicyReferences"); + page.items().forEach(reference -> references.addObject() + .put("Name", reference.name()) + .put("Path", reference.path())); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response detachManagedPolicy(JsonNode request) { service.detachPolicy(SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn"), SsoAdminService.required(request, "ManagedPolicyArn")); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index c105f175c9..f0725b0085 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -802,6 +802,17 @@ public synchronized void detachCustomerManagedPolicyReference(JsonNode request) markPermissionSetProvisioningStale(current.arn()); } + public PaginatedResult listCustomerManagedPolicyReferences(JsonNode request) { + PermissionSet current = getPermissionSet(required(request, "InstanceArn"), required(request, "PermissionSetArn")); + List references = current.customerManagedPolicies().values().stream() + .sorted(Comparator.comparing(CustomerManagedPolicyReference::name, String.CASE_INSENSITIVE_ORDER) + .thenComparing(CustomerManagedPolicyReference::path)) + .toList(); + return Pagination.paginate(references, + reference -> reference.name().toLowerCase(java.util.Locale.ROOT) + "\n" + reference.path(), + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + public synchronized void detachPolicy(String instanceArn, String arn, String policyArn) { PermissionSet current = getPermissionSet(instanceArn, arn); validateManagedPolicyArn(policyArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index b62a76f541..0e120fb4de 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -78,6 +78,11 @@ void detachCustomerManagedPolicyReferenceReturnsEmptyResponse() { + "\",\"CustomerManagedPolicyReference\":{\"Name\":\"PlatformPolicy\",\"Path\":\"/platform/\"}}"; json("SWBExternalService.AttachCustomerManagedPolicyReferenceToPermissionSet", body) .then().statusCode(200); + json("SWBExternalService.ListCustomerManagedPolicyReferencesInPermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") + .then().statusCode(200) + .body("CustomerManagedPolicyReferences[0].Name", equalTo("PlatformPolicy")) + .body("CustomerManagedPolicyReferences[0].Path", equalTo("/platform/")); json("SWBExternalService.DetachCustomerManagedPolicyReferenceFromPermissionSet", body) .then().statusCode(200).body(equalTo("")); } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index d49a773619..64a5fd1d90 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -515,6 +515,12 @@ void attachesCustomerManagedPolicyReferencesWithAwsValidationRules() { service.attachCustomerManagedPolicyReference(request); + ObjectNode list = mapper.createObjectNode(); + list.put("InstanceArn", service.getInstanceArn()); + list.put("PermissionSetArn", permissionSet.arn()); + assertEquals(1, service.listCustomerManagedPolicyReferences(list).items().size()); + assertEquals("PlatformPolicy", service.listCustomerManagedPolicyReferences(list).items().get(0).name()); + PermissionSet stored = service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()); assertEquals(1, stored.customerManagedPolicies().size()); assertEquals("/", stored.customerManagedPolicies().values().iterator().next().path()); From ca88a1640def21ccf230ac53ce448ff1fbff63a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:20:49 -0400 Subject: [PATCH 27/99] feat(ssoadmin): get inline permission set policies --- .../test/SsoAdminAccountAssignmentTest.java | 19 +++++++++++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 32229b7a73..a9c5d87da6 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -530,6 +530,25 @@ void listAccountsForProvisionedPermissionSetUsesAwsSdk() { } } + @Test + @DisplayName("gets inline permission-set policies through the AWS SDK") + void getInlinePolicyUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only permission-set lifecycle"); + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn).name("GetInlinePolicySdkAdmins")) + .permissionSet().permissionSetArn(); + assertThat(sso.getInlinePolicyForPermissionSet(request -> request + .instanceArn(instanceArn).permissionSetArn(permissionSetArn)).inlinePolicy()).isEmpty(); + String policy = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"; + sso.putInlinePolicyToPermissionSet(request -> request + .instanceArn(instanceArn).permissionSetArn(permissionSetArn).inlinePolicy(policy)); + assertThat(sso.getInlinePolicyForPermissionSet(request -> request + .instanceArn(instanceArn).permissionSetArn(permissionSetArn)).inlinePolicy()).isEqualTo(policy); + } + } + @Test @DisplayName("deletes permission sets through the AWS SDK") void deletePermissionSetUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 18346ff42d..3b6273dfbf 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -35,6 +35,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListCustomerManagedPolicyReferencesInPermissionSet` | Lists customer managed IAM policy references attached to a permission set with AWS-compatible pagination. | | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | +| `GetInlinePolicyForPermissionSet` | Returns the inline IAM policy attached to a permission set, or an empty string when none is attached. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 3c38f953ac..2bd6d821ce 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -52,6 +52,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListCustomerManagedPolicyReferencesInPermissionSet" -> listCustomerManagedPolicyReferences(request); case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); + case "GetInlinePolicyForPermissionSet" -> getInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); case "ListAccountAssignments" -> listAccountAssignments(request); case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); @@ -254,6 +255,15 @@ private Response deleteInlinePolicy(JsonNode request) { return Response.ok(mapper.createObjectNode()).build(); } + private Response getInlinePolicy(JsonNode request) { + PermissionSet permissionSet = service.getPermissionSet( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "PermissionSetArn")); + ObjectNode response = mapper.createObjectNode(); + response.put("InlinePolicy", permissionSet.inlinePolicy() == null ? "" : permissionSet.inlinePolicy()); + return Response.ok(response).build(); + } + private Response putInlinePolicy(JsonNode request) { service.putInlinePolicy(SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn"), SsoAdminService.required(request, "InlinePolicy")); From 406e8c0e64c6cd7590b038d5e0240a389d517bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:26:52 -0400 Subject: [PATCH 28/99] feat(ssoadmin): put permission set boundaries --- .../test/SsoAdminAccountAssignmentTest.java | 18 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 52 +++++++++++++++++-- .../ssoadmin/model/PermissionSet.java | 3 +- .../ssoadmin/model/PermissionsBoundary.java | 8 +++ .../SsoAdminAssignmentsIntegrationTest.java | 15 +++++- .../ssoadmin/SsoAdminServiceTest.java | 31 +++++++++++ 8 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionsBoundary.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index a9c5d87da6..26c095de68 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -549,6 +549,24 @@ void getInlinePolicyUsesAwsSdk() { } } + @Test + @DisplayName("puts permission-set permissions boundaries through the AWS SDK") + void putPermissionsBoundaryUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only permission-set lifecycle"); + try (SsoAdminClient sso = TestFixtures.ssoAdminClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String permissionSetArn = sso.createPermissionSet(request -> request + .instanceArn(instanceArn).name("PutBoundarySdkAdmins")) + .permissionSet().permissionSetArn(); + var response = sso.putPermissionsBoundaryToPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn) + .permissionsBoundary(boundary -> boundary + .managedPolicyArn("arn:aws:iam::aws:policy/PowerUserAccess"))); + assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + } + } + @Test @DisplayName("deletes permission sets through the AWS SDK") void deletePermissionSetUsesAwsSdk() { diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 3b6273dfbf..91c3117fbb 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -37,6 +37,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `GetInlinePolicyForPermissionSet` | Returns the inline IAM policy attached to a permission set, or an empty string when none is attached. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | +| `PutPermissionsBoundaryToPermissionSet` | Attaches an AWS managed or customer managed IAM policy as the permissions boundary for a permission set. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | | `ListAccountAssignmentsForPrincipal` | Lists the AWS account and permission set assignments for a user or group, with AccountId filtering and AWS-compatible pagination. | | `ProvisionPermissionSet` | Provisions a permission set to one AWS account or refreshes all previously provisioned accounts, returning an AWS-compatible provisioning status. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 2bd6d821ce..c795170c75 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -54,6 +54,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "GetInlinePolicyForPermissionSet" -> getInlinePolicy(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); + case "PutPermissionsBoundaryToPermissionSet" -> putPermissionsBoundary(request); case "ListAccountAssignments" -> listAccountAssignments(request); case "ListAccountAssignmentsForPrincipal" -> listAccountAssignmentsForPrincipal(request, callerAccountId); case "ProvisionPermissionSet" -> provisionPermissionSet(request); @@ -270,6 +271,11 @@ private Response putInlinePolicy(JsonNode request) { return Response.ok(mapper.createObjectNode()).build(); } + private Response putPermissionsBoundary(JsonNode request) { + service.putPermissionsBoundary(request); + return Response.ok().build(); + } + private Response listAccountAssignments(JsonNode request) { var page = service.listAssignments(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f0725b0085..6d119dfa38 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -23,6 +23,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.OidcJwtIssuerConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionsBoundary; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioning; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; import io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata; @@ -691,7 +692,7 @@ public synchronized PermissionSet createPermissionSet(JsonNode request) { } String arn = "arn:aws:sso:::permissionSet/ssoins-7223b02a5d9f7c8e/ps-" + shortId(); PermissionSet permissionSet = new PermissionSet(arn, name, description, sessionDuration, - new LinkedHashMap<>(), new LinkedHashMap<>(), null); + new LinkedHashMap<>(), new LinkedHashMap<>(), null, null); permissionSets.put(arn, permissionSet); return permissionSet; } @@ -730,7 +731,7 @@ public synchronized PermissionSet updatePermissionSet(JsonNode request) { ? validateSession(required(request, "SessionDuration")) : current.sessionDuration(); PermissionSet updated = new PermissionSet(current.arn(), current.name(), description, session, new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy()); + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), current.permissionsBoundary()); permissionSets.put(updated.arn(), updated); markPermissionSetProvisioningStale(updated.arn()); return updated; @@ -828,7 +829,7 @@ public synchronized void putInlinePolicy(String instanceArn, String arn, String validateInlinePolicy(policy); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), policy)); + new LinkedHashMap<>(current.customerManagedPolicies()), policy, current.permissionsBoundary())); markPermissionSetProvisioningStale(arn); } @@ -836,10 +837,53 @@ public synchronized void deleteInlinePolicy(String instanceArn, String arn) { PermissionSet current = getPermissionSet(instanceArn, arn); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), null)); + new LinkedHashMap<>(current.customerManagedPolicies()), null, current.permissionsBoundary())); markPermissionSetProvisioningStale(arn); } + public synchronized void putPermissionsBoundary(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + String permissionSetArn = required(request, "PermissionSetArn"); + PermissionSet current = getPermissionSet(instanceArn, permissionSetArn); + JsonNode boundaryNode = request.get("PermissionsBoundary"); + if (boundaryNode == null || !boundaryNode.isObject()) { + throw validation("PermissionsBoundary must be an object."); + } + boolean hasCustomerReference = boundaryNode.hasNonNull("CustomerManagedPolicyReference"); + boolean hasManagedPolicyArn = boundaryNode.hasNonNull("ManagedPolicyArn"); + if (hasCustomerReference == hasManagedPolicyArn) { + throw validation("PermissionsBoundary must specify exactly one of CustomerManagedPolicyReference or ManagedPolicyArn."); + } + + PermissionsBoundary boundary; + if (hasManagedPolicyArn) { + String managedPolicyArn = required(boundaryNode, "ManagedPolicyArn"); + validateManagedPolicyArn(managedPolicyArn); + boundary = new PermissionsBoundary(null, managedPolicyArn); + } else { + JsonNode referenceNode = boundaryNode.get("CustomerManagedPolicyReference"); + if (!referenceNode.isObject()) { + throw validation("PermissionsBoundary.CustomerManagedPolicyReference must be an object."); + } + String name = required(referenceNode, "Name"); + if (name.length() > 128 || !CUSTOMER_MANAGED_POLICY_NAME.matcher(name).matches()) { + throw validation("CustomerManagedPolicyReference.Name is invalid."); + } + String path = text(referenceNode, "Path"); + if (path == null) { + path = "/"; + } else if (path.length() > 512 || !CUSTOMER_MANAGED_POLICY_PATH.matcher(path).matches()) { + throw validation("CustomerManagedPolicyReference.Path is invalid."); + } + boundary = new PermissionsBoundary(new CustomerManagedPolicyReference(name, path), null); + } + + permissionSets.put(permissionSetArn, new PermissionSet(current.arn(), current.name(), current.description(), + current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), boundary)); + markPermissionSetProvisioningStale(permissionSetArn); + } + public PaginatedResult listAssignments(JsonNode request) { String instanceArn = required(request, "InstanceArn"); requireInstance(instanceArn); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java index 0e381c2e64..b0a3236d7b 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java @@ -8,7 +8,8 @@ public record PermissionSet(String arn, String name, String description, String sessionDuration, Map managedPolicies, Map customerManagedPolicies, - String inlinePolicy) { + String inlinePolicy, + PermissionsBoundary permissionsBoundary) { public PermissionSet { managedPolicies = managedPolicies == null ? new java.util.LinkedHashMap<>() : managedPolicies; customerManagedPolicies = customerManagedPolicies == null ? new java.util.LinkedHashMap<>() : customerManagedPolicies; diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionsBoundary.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionsBoundary.java new file mode 100644 index 0000000000..9deafa79d4 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionsBoundary.java @@ -0,0 +1,8 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PermissionsBoundary(CustomerManagedPolicyReference customerManagedPolicyReference, + String managedPolicyArn) { +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index 0e120fb4de..c24a192e02 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -29,7 +29,7 @@ void permissionSetAndAccountAssignmentLifecycle() { .then().statusCode(200).body("PermissionSet.PermissionSetArn", notNullValue()) .extract().path("PermissionSet.PermissionSetArn"); json("SWBExternalService.ListPermissionSets", "{\"InstanceArn\":\"" + instanceArn + "\"}") - .then().statusCode(200).body("PermissionSets", hasSize(1)); + .then().statusCode(200).body("PermissionSets", org.hamcrest.Matchers.hasItem(permissionSetArn)); String assignment = "{\"InstanceArn\":\"" + instanceArn + "\",\"TargetId\":\"123456789012\",\"TargetType\":\"AWS_ACCOUNT\",\"PermissionSetArn\":\"" @@ -87,6 +87,19 @@ void detachCustomerManagedPolicyReferenceReturnsEmptyResponse() { .then().statusCode(200).body(equalTo("")); } + @Test + void putPermissionsBoundaryReturnsEmptyResponse() { + String instanceArn = json("SWBExternalService.ListInstances", "{}") + .then().statusCode(200).extract().path("Instances[0].InstanceArn"); + String permissionSetArn = json("SWBExternalService.CreatePermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"BoundaryIntegration\"}") + .then().statusCode(200).extract().path("PermissionSet.PermissionSetArn"); + String body = "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + + "\",\"PermissionsBoundary\":{\"ManagedPolicyArn\":\"arn:aws:iam::aws:policy/PowerUserAccess\"}}"; + json("SWBExternalService.PutPermissionsBoundaryToPermissionSet", body) + .then().statusCode(200).body(equalTo("")); + } + @Test void invalidAssignmentRequestIdReturnsResourceNotFound() { String instanceArn = json("SWBExternalService.ListInstances", "{}") diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 64a5fd1d90..97c2842528 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -860,6 +860,37 @@ void deleteAccountAssignmentValidatesPrincipalTypeAndTargetType() { assertError("ResourceNotFoundException", () -> service.deleteAssignment(wrongPrincipalType)); } + @Test + void putPermissionsBoundarySupportsAwsManagedAndCustomerManagedPolicies() { + PermissionSet permissionSet = createPermissionSet("BoundaryAdmins"); + + ObjectNode managed = mapper.createObjectNode(); + managed.put("InstanceArn", service.getInstanceArn()); + managed.put("PermissionSetArn", permissionSet.arn()); + managed.putObject("PermissionsBoundary") + .put("ManagedPolicyArn", "arn:aws:iam::aws:policy/PowerUserAccess"); + service.putPermissionsBoundary(managed); + assertEquals("arn:aws:iam::aws:policy/PowerUserAccess", + service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()).permissionsBoundary().managedPolicyArn()); + + ObjectNode customer = mapper.createObjectNode(); + customer.put("InstanceArn", service.getInstanceArn()); + customer.put("PermissionSetArn", permissionSet.arn()); + customer.putObject("PermissionsBoundary") + .putObject("CustomerManagedPolicyReference") + .put("Name", "BoundaryPolicy") + .put("Path", "/platform/"); + service.putPermissionsBoundary(customer); + assertEquals("BoundaryPolicy", service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()) + .permissionsBoundary().customerManagedPolicyReference().name()); + + ObjectNode invalid = managed.deepCopy(); + invalid.withObject("PermissionsBoundary") + .putObject("CustomerManagedPolicyReference") + .put("Name", "BoundaryPolicy"); + assertError("ValidationException", () -> service.putPermissionsBoundary(invalid)); + } + @Test void clearRemovesPersistedServiceState() { PermissionSet permissionSet = createPermissionSet("ResetAdmins"); From 17dda0f982beedbd58a1cf6f998e911c79c7da99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:30:40 -0400 Subject: [PATCH 29/99] feat(ssoadmin): get permission set boundaries --- .../test/SsoAdminAccountAssignmentTest.java | 5 +++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 17 +++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 8 ++++++++ .../SsoAdminAssignmentsIntegrationTest.java | 4 ++++ .../services/ssoadmin/SsoAdminServiceTest.java | 4 +++- 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 26c095de68..40cfe0c9f4 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -564,6 +564,11 @@ void putPermissionsBoundaryUsesAwsSdk() { .permissionsBoundary(boundary -> boundary .managedPolicyArn("arn:aws:iam::aws:policy/PowerUserAccess"))); assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + var boundary = sso.getPermissionsBoundaryForPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn)); + assertThat(boundary.permissionsBoundary().managedPolicyArn()) + .isEqualTo("arn:aws:iam::aws:policy/PowerUserAccess"); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 91c3117fbb..feb998cba8 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -36,6 +36,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | | `GetInlinePolicyForPermissionSet` | Returns the inline IAM policy attached to a permission set, or an empty string when none is attached. | +| `GetPermissionsBoundaryForPermissionSet` | Returns the AWS managed or customer managed IAM policy configured as a permission-set permissions boundary. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | | `PutPermissionsBoundaryToPermissionSet` | Attaches an AWS managed or customer managed IAM policy as the permissions boundary for a permission set. | | `ListAccountAssignments` | Lists account assignments with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index c795170c75..cfa719e050 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -53,6 +53,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); case "GetInlinePolicyForPermissionSet" -> getInlinePolicy(request); + case "GetPermissionsBoundaryForPermissionSet" -> getPermissionsBoundary(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); case "PutPermissionsBoundaryToPermissionSet" -> putPermissionsBoundary(request); case "ListAccountAssignments" -> listAccountAssignments(request); @@ -265,6 +266,22 @@ private Response getInlinePolicy(JsonNode request) { return Response.ok(response).build(); } + private Response getPermissionsBoundary(JsonNode request) { + var boundary = service.getPermissionsBoundary( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "PermissionSetArn")); + ObjectNode response = mapper.createObjectNode(); + ObjectNode boundaryNode = response.putObject("PermissionsBoundary"); + if (boundary.managedPolicyArn() != null) { + boundaryNode.put("ManagedPolicyArn", boundary.managedPolicyArn()); + } else { + boundaryNode.putObject("CustomerManagedPolicyReference") + .put("Name", boundary.customerManagedPolicyReference().name()) + .put("Path", boundary.customerManagedPolicyReference().path()); + } + return Response.ok(response).build(); + } + private Response putInlinePolicy(JsonNode request) { service.putInlinePolicy(SsoAdminService.required(request, "InstanceArn"), SsoAdminService.required(request, "PermissionSetArn"), SsoAdminService.required(request, "InlinePolicy")); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6d119dfa38..4e4acbbfb8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -841,6 +841,14 @@ public synchronized void deleteInlinePolicy(String instanceArn, String arn) { markPermissionSetProvisioningStale(arn); } + public PermissionsBoundary getPermissionsBoundary(String instanceArn, String permissionSetArn) { + PermissionSet permissionSet = getPermissionSet(instanceArn, permissionSetArn); + if (permissionSet.permissionsBoundary() == null) { + throw notFound("Permissions boundary not found for permission set: " + permissionSetArn); + } + return permissionSet.permissionsBoundary(); + } + public synchronized void putPermissionsBoundary(JsonNode request) { String instanceArn = required(request, "InstanceArn"); String permissionSetArn = required(request, "PermissionSetArn"); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index c24a192e02..571aac1fc4 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -98,6 +98,10 @@ void putPermissionsBoundaryReturnsEmptyResponse() { + "\",\"PermissionsBoundary\":{\"ManagedPolicyArn\":\"arn:aws:iam::aws:policy/PowerUserAccess\"}}"; json("SWBExternalService.PutPermissionsBoundaryToPermissionSet", body) .then().statusCode(200).body(equalTo("")); + json("SWBExternalService.GetPermissionsBoundaryForPermissionSet", + "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") + .then().statusCode(200) + .body("PermissionsBoundary.ManagedPolicyArn", equalTo("arn:aws:iam::aws:policy/PowerUserAccess")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 97c2842528..ef5873f2a7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -863,6 +863,8 @@ void deleteAccountAssignmentValidatesPrincipalTypeAndTargetType() { @Test void putPermissionsBoundarySupportsAwsManagedAndCustomerManagedPolicies() { PermissionSet permissionSet = createPermissionSet("BoundaryAdmins"); + assertError("ResourceNotFoundException", + () -> service.getPermissionsBoundary(service.getInstanceArn(), permissionSet.arn())); ObjectNode managed = mapper.createObjectNode(); managed.put("InstanceArn", service.getInstanceArn()); @@ -871,7 +873,7 @@ void putPermissionsBoundarySupportsAwsManagedAndCustomerManagedPolicies() { .put("ManagedPolicyArn", "arn:aws:iam::aws:policy/PowerUserAccess"); service.putPermissionsBoundary(managed); assertEquals("arn:aws:iam::aws:policy/PowerUserAccess", - service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()).permissionsBoundary().managedPolicyArn()); + service.getPermissionsBoundary(service.getInstanceArn(), permissionSet.arn()).managedPolicyArn()); ObjectNode customer = mapper.createObjectNode(); customer.put("InstanceArn", service.getInstanceArn()); From e013160fd07007bb83f15a316291fef5ccb99460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:33:53 -0400 Subject: [PATCH 30/99] feat(ssoadmin): delete permission set boundaries --- .../com/floci/test/SsoAdminAccountAssignmentTest.java | 8 ++++++++ docs/services/ssoadmin.md | 1 + .../floci/services/ssoadmin/SsoAdminJsonHandler.java | 8 ++++++++ .../floci/services/ssoadmin/SsoAdminService.java | 11 +++++++++++ .../ssoadmin/SsoAdminAssignmentsIntegrationTest.java | 8 ++++++-- .../floci/services/ssoadmin/SsoAdminServiceTest.java | 5 +++++ 6 files changed, 39 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 40cfe0c9f4..4c3beb91cb 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -569,6 +569,14 @@ void putPermissionsBoundaryUsesAwsSdk() { .permissionSetArn(permissionSetArn)); assertThat(boundary.permissionsBoundary().managedPolicyArn()) .isEqualTo("arn:aws:iam::aws:policy/PowerUserAccess"); + var deleted = sso.deletePermissionsBoundaryFromPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn)); + assertThat(deleted.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.getPermissionsBoundaryForPermissionSet(request -> request + .instanceArn(instanceArn) + .permissionSetArn(permissionSetArn))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index feb998cba8..61b6e041de 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -35,6 +35,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListCustomerManagedPolicyReferencesInPermissionSet` | Lists customer managed IAM policy references attached to a permission set with AWS-compatible pagination. | | `DetachManagedPolicyFromPermissionSet` | Detaches an AWS managed policy. | | `DeleteInlinePolicyFromPermissionSet` | Deletes the inline policy. | +| `DeletePermissionsBoundaryFromPermissionSet` | Removes the permissions boundary from a permission set and marks provisioned copies stale. | | `GetInlinePolicyForPermissionSet` | Returns the inline IAM policy attached to a permission set, or an empty string when none is attached. | | `GetPermissionsBoundaryForPermissionSet` | Returns the AWS managed or customer managed IAM policy configured as a permission-set permissions boundary. | | `PutInlinePolicyToPermissionSet` | Creates or replaces the inline policy. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index cfa719e050..21701b0c59 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -52,6 +52,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListCustomerManagedPolicyReferencesInPermissionSet" -> listCustomerManagedPolicyReferences(request); case "DetachManagedPolicyFromPermissionSet" -> detachManagedPolicy(request); case "DeleteInlinePolicyFromPermissionSet" -> deleteInlinePolicy(request); + case "DeletePermissionsBoundaryFromPermissionSet" -> deletePermissionsBoundary(request); case "GetInlinePolicyForPermissionSet" -> getInlinePolicy(request); case "GetPermissionsBoundaryForPermissionSet" -> getPermissionsBoundary(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); @@ -257,6 +258,13 @@ private Response deleteInlinePolicy(JsonNode request) { return Response.ok(mapper.createObjectNode()).build(); } + private Response deletePermissionsBoundary(JsonNode request) { + service.deletePermissionsBoundary( + SsoAdminService.required(request, "InstanceArn"), + SsoAdminService.required(request, "PermissionSetArn")); + return Response.ok().build(); + } + private Response getInlinePolicy(JsonNode request) { PermissionSet permissionSet = service.getPermissionSet( SsoAdminService.required(request, "InstanceArn"), diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 4e4acbbfb8..e921c3dd09 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -849,6 +849,17 @@ public PermissionsBoundary getPermissionsBoundary(String instanceArn, String per return permissionSet.permissionsBoundary(); } + public synchronized void deletePermissionsBoundary(String instanceArn, String permissionSetArn) { + PermissionSet current = getPermissionSet(instanceArn, permissionSetArn); + if (current.permissionsBoundary() == null) { + throw notFound("Permissions boundary not found for permission set: " + permissionSetArn); + } + permissionSets.put(permissionSetArn, new PermissionSet(current.arn(), current.name(), current.description(), + current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), null)); + markPermissionSetProvisioningStale(permissionSetArn); + } + public synchronized void putPermissionsBoundary(JsonNode request) { String instanceArn = required(request, "InstanceArn"); String permissionSetArn = required(request, "PermissionSetArn"); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java index 571aac1fc4..211e3f94af 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminAssignmentsIntegrationTest.java @@ -98,10 +98,14 @@ void putPermissionsBoundaryReturnsEmptyResponse() { + "\",\"PermissionsBoundary\":{\"ManagedPolicyArn\":\"arn:aws:iam::aws:policy/PowerUserAccess\"}}"; json("SWBExternalService.PutPermissionsBoundaryToPermissionSet", body) .then().statusCode(200).body(equalTo("")); - json("SWBExternalService.GetPermissionsBoundaryForPermissionSet", - "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}") + String getBody = "{\"InstanceArn\":\"" + instanceArn + "\",\"PermissionSetArn\":\"" + permissionSetArn + "\"}"; + json("SWBExternalService.GetPermissionsBoundaryForPermissionSet", getBody) .then().statusCode(200) .body("PermissionsBoundary.ManagedPolicyArn", equalTo("arn:aws:iam::aws:policy/PowerUserAccess")); + json("SWBExternalService.DeletePermissionsBoundaryFromPermissionSet", getBody) + .then().statusCode(200).body(equalTo("")); + json("SWBExternalService.GetPermissionsBoundaryForPermissionSet", getBody) + .then().statusCode(400).body("__type", equalTo("ResourceNotFoundException")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index ef5873f2a7..182914eaec 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -885,6 +885,11 @@ void putPermissionsBoundarySupportsAwsManagedAndCustomerManagedPolicies() { service.putPermissionsBoundary(customer); assertEquals("BoundaryPolicy", service.getPermissionSet(service.getInstanceArn(), permissionSet.arn()) .permissionsBoundary().customerManagedPolicyReference().name()); + service.deletePermissionsBoundary(service.getInstanceArn(), permissionSet.arn()); + assertError("ResourceNotFoundException", + () -> service.getPermissionsBoundary(service.getInstanceArn(), permissionSet.arn())); + assertError("ResourceNotFoundException", + () -> service.deletePermissionsBoundary(service.getInstanceArn(), permissionSet.arn())); ObjectNode invalid = managed.deepCopy(); invalid.withObject("PermissionsBoundary") From 70820f661e2152022d3aa4fd8347bb78725a3ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 12:40:52 -0400 Subject: [PATCH 31/99] feat(scim): create groups --- docs/services/identitystore.md | 8 + .../identitystore/ScimController.java | 211 ++++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 8 + .../identitystore/ScimIntegrationTest.java | 113 ++++++++++ 4 files changed, 340 insertions(+) create mode 100644 src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index bc30b9b69e..d8576b21e3 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -42,6 +42,14 @@ Groups, users, and group memberships are persisted through `StorageFactory` and Deleting a user or group removes its related local group memberships so subsequent membership queries do not retain dangling references. +## IAM Identity Center SCIM + +Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim/v2`. SCIM requests require the AWS-supported `Authorization: Bearer ` authentication shape. Because Floci does not provision real IAM Identity Center access tokens, any non-empty bearer token is accepted locally; the tenant still must resolve to an existing IAM Identity Center identity store. + +`CreateGroup` is supported through `POST /{tenant_id}/scim/v2/Groups`. It requires `displayName`, accepts `externalId`, supports up to 100 user members in one request, returns the AWS SCIM `201` group representation, and persists the group and memberships into the same Identity Store state used by the JSON 1.1 API. A tenant ID beginning with a ten-character identity-store prefix resolves to `d-`; legacy UUID-form identity stores may use the UUID directly. Invalid tenants and missing bearer authorization return SCIM `401` errors. + +SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. + ## AWS-compatible failures Floci validates identity store and resource identifier formats, filter shapes, pagination bounds, membership references, alternate-identifier unions, duplicate user/group names, duplicate memberships, reserved names, operation counts, and local user/group quotas. Deterministic failures use modeled AWS errors including `ValidationException`, `ConflictException`, `ResourceNotFoundException`, and `ServiceQuotaExceededException`. diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java new file mode 100644 index 0000000000..60e54f394f --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -0,0 +1,211 @@ +package io.github.hectorvent.floci.services.identitystore; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.services.identitystore.model.Group; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@ApplicationScoped +@Path("/{tenantId}/scim/v2") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class ScimController { + private static final String GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"; + private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + private static final Pattern PREFIXED_TENANT = Pattern.compile( + "([0-9a-f]{10})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + Pattern.CASE_INSENSITIVE); + private static final Pattern LEGACY_TENANT = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + Pattern.CASE_INSENSITIVE); + + private final IdentityStoreService identityStoreService; + private final SsoAdminService ssoAdminService; + private final ObjectMapper mapper; + + @Inject + public ScimController(IdentityStoreService identityStoreService, SsoAdminService ssoAdminService, ObjectMapper mapper) { + this.identityStoreService = identityStoreService; + this.ssoAdminService = ssoAdminService; + this.mapper = mapper; + } + + @POST + @Path("/Groups") + public Response createGroup(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization, + String body) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + JsonNode request = parseObject(body); + String displayName = requiredText(request, "displayName"); + + JsonNode members = request.get("members"); + if (members != null && !members.isNull() && (!members.isArray() || members.size() > 100)) { + throw validation("members must be an array containing at most 100 users."); + } + + ObjectNode createRequest = mapper.createObjectNode(); + createRequest.put("IdentityStoreId", identityStoreId); + createRequest.put("DisplayName", displayName); + String externalId = optionalText(request, "externalId"); + if (externalId != null) { + createRequest.putArray("ExternalIds").addObject().put("Issuer", GROUP_SCHEMA).put("Id", externalId); + } + + Group group = identityStoreService.createGroup(createRequest); + try { + if (members != null && members.isArray()) { + for (JsonNode member : members) { + createMembership(identityStoreId, group.groupId(), member); + } + } + } catch (RuntimeException failure) { + ObjectNode deleteRequest = mapper.createObjectNode(); + deleteRequest.put("IdentityStoreId", identityStoreId); + deleteRequest.put("GroupId", group.groupId()); + identityStoreService.deleteGroup(deleteRequest); + throw failure; + } + + return Response.status(Response.Status.CREATED).entity(groupResponse(group)).build(); + } catch (AwsException exception) { + return scimError(exception.getHttpStatus(), exception.getMessage()); + } + } + + private void createMembership(String identityStoreId, String groupId, JsonNode member) { + if (member == null || !member.isObject()) { + throw validation("Each members entry must be an object."); + } + String userId = requiredText(member, "value"); + String type = optionalText(member, "type"); + if (type != null && !"User".equals(type)) { + throw validation("members.type must be User when specified."); + } + ObjectNode membershipRequest = mapper.createObjectNode(); + membershipRequest.put("IdentityStoreId", identityStoreId); + membershipRequest.put("GroupId", groupId); + membershipRequest.putObject("MemberId").put("UserId", userId); + identityStoreService.createMembership(membershipRequest); + } + + private ObjectNode groupResponse(Group group) { + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(GROUP_SCHEMA); + response.put("id", group.groupId()); + response.put("displayName", group.displayName()); + String externalId = scimExternalId(group.attributes().get("ExternalIds")); + if (externalId != null) { + response.put("externalId", externalId); + } + ObjectNode meta = response.putObject("meta"); + meta.put("resourceType", "Group"); + meta.put("created", group.createdAt()); + meta.put("lastModified", group.updatedAt()); + return response; + } + + private String resolveIdentityStore(String tenantId) { + if (tenantId == null) { + throw unauthorized("Authorization header is invalid or tenant ID is incorrect."); + } + String identityStoreId; + Matcher prefixed = PREFIXED_TENANT.matcher(tenantId); + if (prefixed.matches()) { + identityStoreId = "d-" + prefixed.group(1).toLowerCase(java.util.Locale.ROOT); + } else if (LEGACY_TENANT.matcher(tenantId).matches()) { + identityStoreId = tenantId.toLowerCase(java.util.Locale.ROOT); + } else { + throw unauthorized("Authorization header is invalid or tenant ID is incorrect."); + } + if (!ssoAdminService.hasIdentityStore(identityStoreId)) { + throw unauthorized("Authorization header is invalid or tenant ID is incorrect."); + } + return identityStoreId; + } + + private static void requireBearer(String authorization) { + if (authorization == null || !authorization.startsWith("Bearer ") || authorization.substring(7).isBlank()) { + throw unauthorized("Authorization header is invalid or missing."); + } + } + + private JsonNode parseObject(String body) { + try { + JsonNode node = mapper.readTree(body == null ? "" : body); + if (node == null || !node.isObject()) { + throw validation("Request is unparsable, syntactically incorrect, or violates schema."); + } + return node; + } catch (JsonProcessingException exception) { + throw validation("Request is unparsable, syntactically incorrect, or violates schema."); + } + } + + private static String requiredText(JsonNode node, String field) { + String value = optionalText(node, field); + if (value == null || value.isBlank()) { + throw validation(field + " is required."); + } + return value; + } + + private static String optionalText(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + if (value == null || value.isNull()) { + return null; + } + if (!value.isTextual()) { + throw validation(field + " must be a string."); + } + return value.textValue(); + } + + private static String scimExternalId(JsonNode externalIds) { + if (externalIds == null || !externalIds.isArray()) { + return null; + } + for (JsonNode externalId : externalIds) { + if (GROUP_SCHEMA.equals(optionalText(externalId, "Issuer"))) { + return optionalText(externalId, "Id"); + } + } + return null; + } + + private Response scimError(int status, String detail) { + ObjectNode error = mapper.createObjectNode(); + ArrayNode schemas = error.putArray("schemas"); + schemas.add(ERROR_SCHEMA); + error.put("detail", detail); + error.put("status", Integer.toString(status)); + return Response.status(status).entity(error).build(); + } + + private static AwsException validation(String message) { + return new AwsException("ValidationException", message, 400); + } + + private static AwsException unauthorized(String message) { + return new AwsException("UnauthorizedException", message, 401); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index e921c3dd09..ac3e75c734 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -189,6 +189,14 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident public String getInstanceArn() { return INSTANCE_ARN; } public String getIdentityStoreId() { return IDENTITY_STORE_ID; } + public boolean hasIdentityStore(String identityStoreId) { + if (identityStoreId == null) { + return false; + } + return instances.scan(key -> true).stream() + .anyMatch(instance -> identityStoreId.equals(instance.identityStoreId())); + } + void ensureBootstrapInstance(String ownerAccountId, String region) { if (instanceDeletionMarkers.get(ownerAccountId).orElse(false)) { return; diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java new file mode 100644 index 0000000000..4319d26e02 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -0,0 +1,113 @@ +package io.github.hectorvent.floci.services.identitystore; + +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.notNullValue; + +@QuarkusTest +class ScimIntegrationTest { + private static final String TENANT = "9067f2a3c1-00000000-0000-0000-0000-000000000000"; + private static final String STORE = "d-9067f2a3c1"; + private static final String BEARER = "Bearer floci-scim-token"; + private static final String AWS_AUTH = "AWS4-HMAC-SHA256 Credential=AKID/20260904/us-east-1/identitystore/aws4_request"; + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @Test + void createGroupUsesAwsScimShapeAndPersistsToIdentityStore() { + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"701984\",\"displayName\":\"SCIM Platform Admins\"}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .body("schemas[0]", equalTo("urn:ietf:params:scim:schemas:core:2.0:Group")) + .body("id", notNullValue()) + .body("externalId", equalTo("701984")) + .body("displayName", equalTo("SCIM Platform Admins")) + .body("meta.resourceType", equalTo("Group")) + .body("meta.created", notNullValue()) + .body("meta.lastModified", notNullValue()) + .extract().path("id"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AWS_AUTH) + .header("X-Amz-Target", "AWSIdentityStore.ListGroups") + .body("{\"IdentityStoreId\":\"" + STORE + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Groups.GroupId", hasItem(groupId)) + .body("Groups.DisplayName", hasItem("SCIM Platform Admins")); + } + + @Test + void createGroupRequiresBearerAndKnownTenant() { + given() + .contentType("application/json") + .body("{\"displayName\":\"Unauthorized Group\"}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(401) + .body("schemas[0]", equalTo("urn:ietf:params:scim:api:messages:2.0:Error")) + .body("status", equalTo("401")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"displayName\":\"Wrong Tenant Group\"}") + .when() + .post("/aaaaaaaaaa-00000000-0000-0000-0000-000000000000/scim/v2/Groups") + .then() + .statusCode(401) + .body("status", equalTo("401")); + } + + @Test + void createGroupValidatesRequiredDisplayNameAndMemberLimit() { + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(400) + .body("status", equalTo("400")); + + StringBuilder members = new StringBuilder("[\n"); + for (int i = 0; i < 101; i++) { + if (i > 0) { + members.append(','); + } + members.append("{\"value\":\"11111111-2222-3333-4444-") + .append(String.format("%012d", i)) + .append("\"}"); + } + members.append(']'); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"displayName\":\"Too Many Members\",\"members\":" + members + "}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(400) + .body("status", equalTo("400")); + } +} From 757d53e2332897b7b579870bc4faec99baa7c1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:03:22 -0400 Subject: [PATCH 32/99] feat(scim): create users --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 263 +++++++++++++++++- .../identitystore/ScimIntegrationTest.java | 79 ++++++ 3 files changed, 341 insertions(+), 3 deletions(-) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index d8576b21e3..e8068e11cf 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -48,6 +48,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `CreateGroup` is supported through `POST /{tenant_id}/scim/v2/Groups`. It requires `displayName`, accepts `externalId`, supports up to 100 user members in one request, returns the AWS SCIM `201` group representation, and persists the group and memberships into the same Identity Store state used by the JSON 1.1 API. A tenant ID beginning with a ten-character identity-store prefix resolves to `d-`; legacy UUID-form identity stores may use the UUID directly. Invalid tenants and missing bearer authorization return SCIM `401` errors. +`CreateUser` is supported through `POST /{tenant_id}/scim/v2/Users`. It enforces the IAM Identity Center SCIM requirements for `givenName`, `familyName`, `userName`, and `displayName`; allows only one value for multi-value attributes such as `emails`, `addresses`, `phoneNumbers`, and `roles`; requires the email value to be primary; rejects `groups` during creation and the AWS-documented unsupported attributes/subattributes; and persists the resulting user into the shared Identity Store state. The response uses the AWS SCIM `201` user representation, including the enterprise extension when supplied. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 60e54f394f..43cca157b2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.identitystore.model.Group; +import io.github.hectorvent.floci.services.identitystore.model.User; import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -19,6 +20,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -28,6 +30,9 @@ @Produces(MediaType.APPLICATION_JSON) public class ScimController { private static final String GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"; + private static final String USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; + private static final String ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; + private static final String IDENTITYSTORE_ENTERPRISE_EXTENSION = "aws:identitystore:enterprise"; private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; private static final Pattern PREFIXED_TENANT = Pattern.compile( "([0-9a-f]{10})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", @@ -47,6 +52,23 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @POST + @Path("/Users") + public Response createUser(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization, + String body) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + JsonNode request = parseObject(body); + validateCreateUser(request); + User user = identityStoreService.createUser(toIdentityStoreUser(identityStoreId, request)); + return Response.status(Response.Status.CREATED).entity(userResponse(user)).build(); + } catch (AwsException exception) { + return scimError(exception.getHttpStatus(), exception.getMessage()); + } + } + @POST @Path("/Groups") public Response createGroup(@PathParam("tenantId") String tenantId, @@ -92,6 +114,191 @@ public Response createGroup(@PathParam("tenantId") String tenantId, } } + private void validateCreateUser(JsonNode request) { + requiredText(request, "userName"); + requiredText(request, "displayName"); + JsonNode name = request.get("name"); + if (name == null || !name.isObject()) { + throw validation("name is required."); + } + requiredText(name, "givenName"); + requiredText(name, "familyName"); + + if (request.has("groups")) { + throw validation("groups cannot be specified when creating a user."); + } + for (String unsupported : java.util.List.of("ims", "photos", "x509Certificates", "entitlements", "password")) { + if (request.has(unsupported)) { + throw validation(unsupported + " is not supported."); + } + } + + validateSingleValueArray(request, "emails", true, true); + validateSingleValueArray(request, "addresses", false, true); + validateSingleValueArray(request, "phoneNumbers", false, true); + validateSingleValueArray(request, "roles", false, false); + + JsonNode roles = request.get("roles"); + if (roles != null && roles.isArray() && !roles.isEmpty() && roles.get(0).has("display")) { + throw validation("roles.display is not supported."); + } + JsonNode enterprise = request.get(ENTERPRISE_USER_SCHEMA); + if (enterprise != null && !enterprise.isNull()) { + if (!enterprise.isObject()) { + throw validation(ENTERPRISE_USER_SCHEMA + " must be an object."); + } + JsonNode manager = enterprise.get("manager"); + if (manager != null && manager.isObject() && manager.has("displayName")) { + throw validation("manager.displayName is not supported."); + } + } + JsonNode active = request.get("active"); + if (active != null && !active.isBoolean()) { + throw validation("active must be a boolean."); + } + } + + private void validateSingleValueArray(JsonNode request, String field, boolean requirePrimary, boolean rejectDisplay) { + JsonNode values = request.get(field); + if (values == null || values.isNull()) { + return; + } + if (!values.isArray() || values.size() != 1 || !values.get(0).isObject()) { + throw validation(field + " must contain exactly one value when specified."); + } + JsonNode value = values.get(0); + if (rejectDisplay && value.has("display")) { + throw validation(field + ".display is not supported."); + } + if (requirePrimary && (!value.path("primary").isBoolean() || !value.path("primary").booleanValue())) { + throw validation(field + " must be marked primary."); + } + } + + private ObjectNode toIdentityStoreUser(String identityStoreId, JsonNode request) { + ObjectNode out = mapper.createObjectNode(); + out.put("IdentityStoreId", identityStoreId); + copyText(request, out, "userName", "UserName"); + copyText(request, out, "displayName", "DisplayName"); + copyText(request, out, "nickName", "NickName"); + copyText(request, out, "profileUrl", "ProfileUrl"); + copyText(request, out, "userType", "UserType"); + copyText(request, out, "title", "Title"); + copyText(request, out, "preferredLanguage", "PreferredLanguage"); + copyText(request, out, "locale", "Locale"); + copyText(request, out, "timezone", "Timezone"); + copyText(request, out, "birthdate", "Birthdate"); + out.put("UserStatus", request.path("active").isBoolean() && !request.path("active").booleanValue() + ? "DISABLED" : "ENABLED"); + + String externalId = optionalText(request, "externalId"); + if (externalId != null) { + out.putArray("ExternalIds").addObject().put("Issuer", USER_SCHEMA).put("Id", externalId); + } + + ObjectNode sourceName = (ObjectNode) request.get("name"); + ObjectNode targetName = out.putObject("Name"); + copyText(sourceName, targetName, "formatted", "Formatted"); + copyText(sourceName, targetName, "familyName", "FamilyName"); + copyText(sourceName, targetName, "givenName", "GivenName"); + copyText(sourceName, targetName, "middleName", "MiddleName"); + copyText(sourceName, targetName, "honorificPrefix", "HonorificPrefix"); + copyText(sourceName, targetName, "honorificSuffix", "HonorificSuffix"); + + copyObjectArray(request, out, "emails", "Emails", + Map.of("value", "Value", "type", "Type", "primary", "Primary")); + copyObjectArray(request, out, "addresses", "Addresses", + Map.ofEntries( + Map.entry("formatted", "Formatted"), + Map.entry("streetAddress", "StreetAddress"), + Map.entry("locality", "Locality"), + Map.entry("region", "Region"), + Map.entry("postalCode", "PostalCode"), + Map.entry("country", "Country"), + Map.entry("type", "Type"), + Map.entry("primary", "Primary"))); + copyObjectArray(request, out, "phoneNumbers", "PhoneNumbers", + Map.of("value", "Value", "type", "Type", "primary", "Primary")); + copyObjectArray(request, out, "roles", "Roles", + Map.of("value", "Value", "type", "Type", "primary", "Primary")); + + JsonNode enterprise = request.get(ENTERPRISE_USER_SCHEMA); + if (enterprise != null && enterprise.isObject()) { + out.putObject("Extensions").set(IDENTITYSTORE_ENTERPRISE_EXTENSION, enterprise.deepCopy()); + } + return out; + } + + private ObjectNode userResponse(User user) { + ObjectNode response = mapper.createObjectNode(); + ArrayNode schemas = response.putArray("schemas"); + schemas.add(USER_SCHEMA); + response.put("id", user.userId()); + String externalId = scimExternalId(user.attributes().get("ExternalIds"), USER_SCHEMA); + if (externalId != null) { + response.put("externalId", externalId); + } + ObjectNode meta = response.putObject("meta"); + meta.put("resourceType", "User"); + meta.put("created", user.createdAt()); + meta.put("lastModified", user.updatedAt()); + + copyTextBack(user.attributes(), response, "UserName", "userName"); + copyTextBack(user.attributes(), response, "DisplayName", "displayName"); + copyTextBack(user.attributes(), response, "NickName", "nickName"); + copyTextBack(user.attributes(), response, "ProfileUrl", "profileUrl"); + copyTextBack(user.attributes(), response, "UserType", "userType"); + copyTextBack(user.attributes(), response, "Title", "title"); + copyTextBack(user.attributes(), response, "PreferredLanguage", "preferredLanguage"); + copyTextBack(user.attributes(), response, "Locale", "locale"); + copyTextBack(user.attributes(), response, "Timezone", "timezone"); + copyTextBack(user.attributes(), response, "Birthdate", "birthdate"); + response.put("active", !"DISABLED".equals(optionalText(user.attributes(), "UserStatus"))); + + JsonNode name = user.attributes().get("Name"); + if (name != null && name.isObject()) { + ObjectNode targetName = response.putObject("name"); + copyTextBack(name, targetName, "Formatted", "formatted"); + copyTextBack(name, targetName, "FamilyName", "familyName"); + copyTextBack(name, targetName, "GivenName", "givenName"); + copyTextBack(name, targetName, "MiddleName", "middleName"); + copyTextBack(name, targetName, "HonorificPrefix", "honorificPrefix"); + copyTextBack(name, targetName, "HonorificSuffix", "honorificSuffix"); + } + copyObjectArrayBack(user.attributes(), response, "Emails", "emails", + Map.of("Value", "value", "Type", "type", "Primary", "primary")); + copyObjectArrayBack(user.attributes(), response, "Addresses", "addresses", + Map.ofEntries( + Map.entry("Formatted", "formatted"), + Map.entry("StreetAddress", "streetAddress"), + Map.entry("Locality", "locality"), + Map.entry("Region", "region"), + Map.entry("PostalCode", "postalCode"), + Map.entry("Country", "country"), + Map.entry("Type", "type"), + Map.entry("Primary", "primary"))); + copyObjectArrayBack(user.attributes(), response, "PhoneNumbers", "phoneNumbers", + Map.of("Value", "value", "Type", "type", "Primary", "primary")); + copyObjectArrayBack(user.attributes(), response, "Roles", "roles", + Map.of("Value", "value", "Type", "type", "Primary", "primary")); + + JsonNode extensions = user.attributes().get("Extensions"); + if (extensions != null && extensions.isObject()) { + JsonNode enterprise = extensions.get(IDENTITYSTORE_ENTERPRISE_EXTENSION); + if (enterprise != null && enterprise.isObject()) { + schemas.add(ENTERPRISE_USER_SCHEMA); + ObjectNode enterpriseResponse = enterprise.deepCopy(); + JsonNode manager = enterpriseResponse.get("manager"); + if (manager instanceof ObjectNode managerObject) { + managerObject.remove("$ref"); + managerObject.remove("displayName"); + } + response.set(ENTERPRISE_USER_SCHEMA, enterpriseResponse); + } + } + return response; + } + private void createMembership(String identityStoreId, String groupId, JsonNode member) { if (member == null || !member.isObject()) { throw validation("Each members entry must be an object."); @@ -113,7 +320,7 @@ private ObjectNode groupResponse(Group group) { response.putArray("schemas").add(GROUP_SCHEMA); response.put("id", group.groupId()); response.put("displayName", group.displayName()); - String externalId = scimExternalId(group.attributes().get("ExternalIds")); + String externalId = scimExternalId(group.attributes().get("ExternalIds"), GROUP_SCHEMA); if (externalId != null) { response.put("externalId", externalId); } @@ -161,6 +368,56 @@ private JsonNode parseObject(String body) { } } + private void copyText(JsonNode source, ObjectNode target, String sourceField, String targetField) { + String value = optionalText(source, sourceField); + if (value != null) { + target.put(targetField, value); + } + } + + private static void copyTextBack(JsonNode source, ObjectNode target, String sourceField, String targetField) { + JsonNode value = source == null ? null : source.get(sourceField); + if (value != null && value.isTextual()) { + target.put(targetField, value.textValue()); + } + } + + private void copyObjectArray(JsonNode source, ObjectNode target, String sourceField, String targetField, + Map fields) { + JsonNode array = source.get(sourceField); + if (array == null || !array.isArray()) { + return; + } + ArrayNode targetArray = target.putArray(targetField); + for (JsonNode item : array) { + ObjectNode targetItem = targetArray.addObject(); + for (var field : fields.entrySet()) { + JsonNode value = item.get(field.getKey()); + if (value != null && !value.isNull()) { + targetItem.set(field.getValue(), value.deepCopy()); + } + } + } + } + + private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sourceField, String targetField, + Map fields) { + JsonNode array = source.get(sourceField); + if (array == null || !array.isArray()) { + return; + } + ArrayNode targetArray = target.putArray(targetField); + for (JsonNode item : array) { + ObjectNode targetItem = targetArray.addObject(); + for (var field : fields.entrySet()) { + JsonNode value = item.get(field.getKey()); + if (value != null && !value.isNull()) { + targetItem.set(field.getValue(), value.deepCopy()); + } + } + } + } + private static String requiredText(JsonNode node, String field) { String value = optionalText(node, field); if (value == null || value.isBlank()) { @@ -180,12 +437,12 @@ private static String optionalText(JsonNode node, String field) { return value.textValue(); } - private static String scimExternalId(JsonNode externalIds) { + private static String scimExternalId(JsonNode externalIds, String issuer) { if (externalIds == null || !externalIds.isArray()) { return null; } for (JsonNode externalId : externalIds) { - if (GROUP_SCHEMA.equals(optionalText(externalId, "Issuer"))) { + if (issuer.equals(optionalText(externalId, "Issuer"))) { return optionalText(externalId, "Id"); } } diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 4319d26e02..32a8d65156 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -54,6 +54,85 @@ void createGroupUsesAwsScimShapeAndPersistsToIdentityStore() { .body("Groups.DisplayName", hasItem("SCIM Platform Admins")); } + @Test + void createUserUsesAwsScimShapeAndPersistsToIdentityStore() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"2819\",\"userName\":\"bjensen@example.com\"," + + "\"displayName\":\"Babs Jensen\",\"active\":true," + + "\"name\":{\"formatted\":\"Ms. Barbara J Jensen III\"," + + "\"familyName\":\"Jensen\",\"givenName\":\"Barbara\",\"middleName\":\"Jane\"}," + + "\"emails\":[{\"value\":\"bjensen@example.com\",\"type\":\"work\",\"primary\":true}]}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .body("schemas[0]", equalTo("urn:ietf:params:scim:schemas:core:2.0:User")) + .body("id", notNullValue()) + .body("externalId", equalTo("2819")) + .body("userName", equalTo("bjensen@example.com")) + .body("displayName", equalTo("Babs Jensen")) + .body("name.familyName", equalTo("Jensen")) + .body("name.givenName", equalTo("Barbara")) + .body("emails[0].value", equalTo("bjensen@example.com")) + .body("emails[0].primary", equalTo(true)) + .body("active", equalTo(true)) + .body("meta.resourceType", equalTo("User")) + .extract().path("id"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AWS_AUTH) + .header("X-Amz-Target", "AWSIdentityStore.DescribeUser") + .body("{\"IdentityStoreId\":\"" + STORE + "\",\"UserId\":\"" + userId + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("UserName", equalTo("bjensen@example.com")) + .body("DisplayName", equalTo("Babs Jensen")) + .body("Name.FamilyName", equalTo("Jensen")) + .body("Emails[0].Value", equalTo("bjensen@example.com")); + } + + @Test + void createUserValidatesAwsUnsupportedAttributesAndRequiredFields() { + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"missing@example.com\",\"displayName\":\"Missing Name\"}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"groups@example.com\",\"displayName\":\"Groups User\"," + + "\"name\":{\"givenName\":\"Groups\",\"familyName\":\"User\"},\"groups\":[]}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"multi@example.com\",\"displayName\":\"Multi Email\"," + + "\"name\":{\"givenName\":\"Multi\",\"familyName\":\"Email\"}," + + "\"emails\":[{\"value\":\"one@example.com\",\"primary\":true}," + + "{\"value\":\"two@example.com\",\"primary\":true}]}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(400) + .body("status", equalTo("400")); + } + @Test void createGroupRequiresBearerAndKnownTenant() { given() From e5774a5a53aadba7e8a55d304a25f45bd2ef9de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:07:01 -0400 Subject: [PATCH 33/99] feat(scim): delete groups --- docs/services/identitystore.md | 2 ++ .../identitystore/ScimController.java | 36 +++++++++++++++++-- .../identitystore/ScimIntegrationTest.java | 28 +++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index e8068e11cf..c698d26f40 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -50,6 +50,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `CreateUser` is supported through `POST /{tenant_id}/scim/v2/Users`. It enforces the IAM Identity Center SCIM requirements for `givenName`, `familyName`, `userName`, and `displayName`; allows only one value for multi-value attributes such as `emails`, `addresses`, `phoneNumbers`, and `roles`; requires the email value to be primary; rejects `groups` during creation and the AWS-documented unsupported attributes/subattributes; and persists the resulting user into the shared Identity Store state. The response uses the AWS SCIM `201` user representation, including the enterprise extension when supplied. +`DeleteGroup` is supported through `DELETE /{tenant_id}/scim/v2/Groups/{id}` and returns HTTP `204` with an empty body. Deletion removes the shared Identity Store group and its local membership records. Missing groups return the SCIM-documented HTTP `404` `ResourceNotFoundException` mapping. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 43cca157b2..da981cdc9a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -12,6 +12,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -52,6 +53,24 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @DELETE + @Path("/Groups/{groupId}") + public Response deleteGroup(@PathParam("tenantId") String tenantId, + @PathParam("groupId") String groupId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + ObjectNode request = mapper.createObjectNode(); + request.put("IdentityStoreId", identityStoreId); + request.put("GroupId", groupId); + identityStoreService.deleteGroup(request); + return Response.noContent().build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @POST @Path("/Users") public Response createUser(@PathParam("tenantId") String tenantId, @@ -65,7 +84,7 @@ public Response createUser(@PathParam("tenantId") String tenantId, User user = identityStoreService.createUser(toIdentityStoreUser(identityStoreId, request)); return Response.status(Response.Status.CREATED).entity(userResponse(user)).build(); } catch (AwsException exception) { - return scimError(exception.getHttpStatus(), exception.getMessage()); + return scimError(scimStatus(exception), exception.getMessage()); } } @@ -110,7 +129,7 @@ public Response createGroup(@PathParam("tenantId") String tenantId, return Response.status(Response.Status.CREATED).entity(groupResponse(group)).build(); } catch (AwsException exception) { - return scimError(exception.getHttpStatus(), exception.getMessage()); + return scimError(scimStatus(exception), exception.getMessage()); } } @@ -449,6 +468,19 @@ private static String scimExternalId(JsonNode externalIds, String issuer) { return null; } + private static int scimStatus(AwsException exception) { + return switch (exception.getErrorCode()) { + case "ValidationException" -> 400; + case "UnauthorizedException" -> 401; + case "AccessDeniedException" -> 403; + case "ResourceNotFoundException" -> 404; + case "ConflictException" -> 409; + case "ThrottlingException" -> 429; + case "InternalServerException" -> 500; + default -> exception.getHttpStatus(); + }; + } + private Response scimError(int status, String detail) { ObjectNode error = mapper.createObjectNode(); ArrayNode schemas = error.putArray("schemas"); diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 32a8d65156..c1f8a0b3c9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -133,6 +133,34 @@ void createUserValidatesAwsUnsupportedAttributesAndRequiredFields() { .body("status", equalTo("400")); } + @Test + void deleteGroupUsesAwsScimStatusAndRemovesIdentityStoreResource() { + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"displayName\":\"SCIM Delete Me\"}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .when() + .delete("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(204); + + given() + .header("Authorization", BEARER) + .when() + .delete("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(404) + .body("status", equalTo("404")); + } + @Test void createGroupRequiresBearerAndKnownTenant() { given() From 5b0d3c4a63a7b32af412ce8bfd6aee408bca1c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:08:37 -0400 Subject: [PATCH 34/99] feat(scim): delete users --- docs/services/identitystore.md | 2 ++ .../identitystore/ScimController.java | 18 ++++++++++++ .../identitystore/ScimIntegrationTest.java | 29 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index c698d26f40..f768b52e7f 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -52,6 +52,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `DeleteGroup` is supported through `DELETE /{tenant_id}/scim/v2/Groups/{id}` and returns HTTP `204` with an empty body. Deletion removes the shared Identity Store group and its local membership records. Missing groups return the SCIM-documented HTTP `404` `ResourceNotFoundException` mapping. +`DeleteUser` is supported through `DELETE /{tenant_id}/scim/v2/Users/{id}` and returns HTTP `204` with an empty body. Deletion removes the shared Identity Store user and any local group memberships that reference it. Missing users return HTTP `404`. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index da981cdc9a..a932c8b99f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -53,6 +53,24 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @DELETE + @Path("/Users/{userId}") + public Response deleteUser(@PathParam("tenantId") String tenantId, + @PathParam("userId") String userId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + ObjectNode request = mapper.createObjectNode(); + request.put("IdentityStoreId", identityStoreId); + request.put("UserId", userId); + identityStoreService.deleteUser(request); + return Response.noContent().build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @DELETE @Path("/Groups/{groupId}") public Response deleteGroup(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index c1f8a0b3c9..6ec265ea8a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -133,6 +133,35 @@ void createUserValidatesAwsUnsupportedAttributesAndRequiredFields() { .body("status", equalTo("400")); } + @Test + void deleteUserUsesAwsScimStatusAndRemovesIdentityStoreResource() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"delete@example.com\",\"displayName\":\"Delete User\"," + + "\"name\":{\"givenName\":\"Delete\",\"familyName\":\"User\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .when() + .delete("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(204); + + given() + .header("Authorization", BEARER) + .when() + .delete("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(404) + .body("status", equalTo("404")); + } + @Test void deleteGroupUsesAwsScimStatusAndRemovesIdentityStoreResource() { String groupId = given() From 7102eeb7f676835a2a2804313bc9fe26171dc7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:10:33 -0400 Subject: [PATCH 35/99] feat(scim): get groups --- docs/services/identitystore.md | 2 ++ .../identitystore/ScimController.java | 18 ++++++++++ .../identitystore/ScimIntegrationTest.java | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index f768b52e7f..e617082b10 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -54,6 +54,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `DeleteUser` is supported through `DELETE /{tenant_id}/scim/v2/Users/{id}` and returns HTTP `204` with an empty body. Deletion removes the shared Identity Store user and any local group memberships that reference it. Missing users return HTTP `404`. +`GetGroup` is supported through `GET /{tenant_id}/scim/v2/Groups/{id}`. It returns the SCIM group representation and, matching IAM Identity Center, does not expand group members in this response. Missing groups return HTTP `404`. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index a932c8b99f..a9c418d6d9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -13,6 +13,7 @@ import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; @@ -53,6 +54,23 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/Groups/{groupId}") + public Response getGroup(@PathParam("tenantId") String tenantId, + @PathParam("groupId") String groupId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + ObjectNode request = mapper.createObjectNode(); + request.put("IdentityStoreId", identityStoreId); + request.put("GroupId", groupId); + return Response.ok(groupResponse(identityStoreService.describeGroup(request))).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @DELETE @Path("/Users/{userId}") public Response deleteUser(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 6ec265ea8a..f46babd798 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -133,6 +133,41 @@ void createUserValidatesAwsUnsupportedAttributesAndRequiredFields() { .body("status", equalTo("400")); } + @Test + void getGroupUsesAwsScimRepresentation() { + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"get-group-ext\",\"displayName\":\"SCIM Get Group\"}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:schemas:core:2.0:Group")) + .body("id", equalTo(groupId)) + .body("externalId", equalTo("get-group-ext")) + .body("displayName", equalTo("SCIM Get Group")) + .body("meta.resourceType", equalTo("Group")) + .body("meta.created", notNullValue()) + .body("meta.lastModified", notNullValue()); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Groups/9067f2a3c1-00000000-0000-0000-0000-000000000099") + .then() + .statusCode(404) + .body("status", equalTo("404")); + } + @Test void deleteUserUsesAwsScimStatusAndRemovesIdentityStoreResource() { String userId = given() From 361ef360a76acde7ef767f12d72cd269c1947f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:12:17 -0400 Subject: [PATCH 36/99] feat(scim): get users --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 17 +++++++++ .../identitystore/ScimIntegrationTest.java | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index e617082b10..86553d4799 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -56,6 +56,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `GetGroup` is supported through `GET /{tenant_id}/scim/v2/Groups/{id}`. It returns the SCIM group representation and, matching IAM Identity Center, does not expand group members in this response. Missing groups return HTTP `404`. +`GetUser` is supported through `GET /{tenant_id}/scim/v2/Users/{id}` and returns the persisted SCIM user representation, including the enterprise extension when present. Missing users return HTTP `404`. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index a9c418d6d9..35decfec30 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -54,6 +54,23 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/Users/{userId}") + public Response getUser(@PathParam("tenantId") String tenantId, + @PathParam("userId") String userId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + ObjectNode request = mapper.createObjectNode(); + request.put("IdentityStoreId", identityStoreId); + request.put("UserId", userId); + return Response.ok(userResponse(identityStoreService.describeUser(request))).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Groups/{groupId}") public Response getGroup(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index f46babd798..2b15e2ff44 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -133,6 +133,43 @@ void createUserValidatesAwsUnsupportedAttributesAndRequiredFields() { .body("status", equalTo("400")); } + @Test + void getUserUsesAwsScimRepresentation() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"get-user-ext\",\"userName\":\"get@example.com\"," + + "\"displayName\":\"Get User\",\"name\":{\"givenName\":\"Get\",\"familyName\":\"User\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:schemas:core:2.0:User")) + .body("id", equalTo(userId)) + .body("externalId", equalTo("get-user-ext")) + .body("userName", equalTo("get@example.com")) + .body("displayName", equalTo("Get User")) + .body("name.givenName", equalTo("Get")) + .body("name.familyName", equalTo("User")) + .body("meta.resourceType", equalTo("User")); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Users/9067f2a3c1-00000000-0000-0000-0000-000000000098") + .then() + .statusCode(404) + .body("status", equalTo("404")); + } + @Test void getGroupUsesAwsScimRepresentation() { String groupId = given() From e3d6f509ab1a9b1c77bd42ec81b8f0553047fc4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:37:15 -0400 Subject: [PATCH 37/99] feat(scim): list groups --- docs/services/identitystore.md | 2 + .../identitystore/IdentityStoreService.java | 10 ++ .../identitystore/ScimController.java | 165 ++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 91 ++++++++++ 4 files changed, 268 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 86553d4799..cea1bb3c08 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -58,6 +58,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `GetUser` is supported through `GET /{tenant_id}/scim/v2/Users/{id}` and returns the persisted SCIM user representation, including the enterprise extension when present. Missing users return HTTP `404`. +`ListGroups` is supported through `GET /{tenant_id}/scim/v2/Groups`. It implements IAM Identity Center's `displayName`, `externalId`, `members.value`, `id`, and combined `id`/`member` equality filters, `count` from 1 through 100, and the AWS cursor pagination contract. The first cursor request uses an empty `cursor`; subsequent cursors preserve the original filter and reject filter changes. Non-paginated responses contain `totalResults`, `startIndex`, and `itemsPerPage`, while cursor responses use `itemsPerPage` and `nextCursor`. Group resources expose an empty `members` list, matching the IAM Identity Center SCIM limitation. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index b995b469c6..d646db8ee2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java @@ -98,6 +98,16 @@ public PaginatedResult listGroups(JsonNode request) { optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + List listGroupsForScim(String storeId) { + return listGroupsAll(requireStore(storeId), null); + } + + List listMembershipsForScim(String storeId) { + return listMembershipsAll(requireStore(storeId)).stream() + .sorted(Comparator.comparing(Membership::membershipId)) + .toList(); + } + public Group describeGroup(JsonNode request) { String storeId = requireStore(required(request, "IdentityStoreId")); return requireGroup(storeId, requireResourceId(required(request, "GroupId"), "GroupId")); diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 35decfec30..108d9b58cb 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.identitystore.model.Group; +import io.github.hectorvent.floci.services.identitystore.model.Membership; import io.github.hectorvent.floci.services.identitystore.model.User; import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; import jakarta.enterprise.context.ApplicationScoped; @@ -19,9 +20,15 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -36,6 +43,12 @@ public class ScimController { private static final String ENTERPRISE_USER_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"; private static final String IDENTITYSTORE_ENTERPRISE_EXTENSION = "aws:identitystore:enterprise"; private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; + private static final String LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + private static final Pattern CURSOR_PATTERN = Pattern.compile("[-a-zA-Z0-9+=/:_]*"); + private static final Pattern SINGLE_GROUP_FILTER = Pattern.compile( + "^(displayName|externalId|members\\.value|id) eq \\\"([^\\\"]*)\\\"$"); + private static final Pattern DOUBLE_GROUP_FILTER = Pattern.compile( + "^(id|member) eq \\\"([^\\\"]*)\\\" and (id|member) eq \\\"([^\\\"]*)\\\"$"); private static final Pattern PREFIXED_TENANT = Pattern.compile( "([0-9a-f]{10})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); @@ -71,6 +84,48 @@ public Response getUser(@PathParam("tenantId") String tenantId, } } + @GET + @Path("/Groups") + public Response listGroups(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization, + @QueryParam("filter") String filter, + @QueryParam("count") String countValue, + @QueryParam("cursor") String cursor, + @Context UriInfo uriInfo) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + validateListQueryParameters(uriInfo, List.of("filter", "count", "cursor")); + int count = scimCount(countValue); + boolean cursorPresent = uriInfo.getQueryParameters().containsKey("cursor"); + List matching = filterScimGroups(identityStoreId, filter); + int offset = cursorPresent ? decodeCursor(cursor, filter) : 0; + if (offset > matching.size()) { + throw validation("cursor is invalid."); + } + int end = Math.min(offset + count, matching.size()); + + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(LIST_SCHEMA); + ArrayNode resources = response.putArray("Resources"); + for (Group group : matching.subList(offset, end)) { + resources.add(groupResponse(group)); + } + response.put("itemsPerPage", end - offset); + if (cursorPresent) { + if (end < matching.size()) { + response.put("nextCursor", encodeCursor(end, filter)); + } + } else { + response.put("totalResults", matching.size()); + response.put("startIndex", 1); + } + return Response.ok(response).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Groups/{groupId}") public Response getGroup(@PathParam("tenantId") String tenantId, @@ -392,6 +447,7 @@ private ObjectNode groupResponse(Group group) { response.putArray("schemas").add(GROUP_SCHEMA); response.put("id", group.groupId()); response.put("displayName", group.displayName()); + response.putArray("members"); String externalId = scimExternalId(group.attributes().get("ExternalIds"), GROUP_SCHEMA); if (externalId != null) { response.put("externalId", externalId); @@ -490,6 +546,115 @@ private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sour } } + private List filterScimGroups(String identityStoreId, String filter) { + List groups = identityStoreService.listGroupsForScim(identityStoreId); + if (filter == null || filter.isBlank()) { + if (filter != null && !filter.isEmpty()) { + throw validation("filter is invalid."); + } + return groups; + } + + Matcher single = SINGLE_GROUP_FILTER.matcher(filter); + if (single.matches()) { + String attribute = single.group(1); + String value = single.group(2); + return switch (attribute) { + case "displayName" -> groups.stream() + .filter(group -> value.equals(group.displayName())) + .toList(); + case "externalId" -> groups.stream() + .filter(group -> value.equals(scimExternalId(group.attributes().get("ExternalIds"), GROUP_SCHEMA))) + .toList(); + case "id" -> groups.stream() + .filter(group -> value.equals(group.groupId())) + .toList(); + case "members.value" -> groupsForMember(identityStoreId, groups, value); + default -> throw validation("filter is invalid."); + }; + } + + Matcher combined = DOUBLE_GROUP_FILTER.matcher(filter); + if (!combined.matches() || combined.group(1).equals(combined.group(3))) { + throw validation("filter is invalid."); + } + String groupId = "id".equals(combined.group(1)) ? combined.group(2) : combined.group(4); + String memberId = "member".equals(combined.group(1)) ? combined.group(2) : combined.group(4); + requireScimUser(identityStoreId, memberId); + boolean member = identityStoreService.listMembershipsForScim(identityStoreId).stream() + .anyMatch(membership -> groupId.equals(membership.groupId()) && memberId.equals(membership.userId())); + return member ? groups.stream().filter(group -> groupId.equals(group.groupId())).toList() : List.of(); + } + + private List groupsForMember(String identityStoreId, List groups, String memberId) { + requireScimUser(identityStoreId, memberId); + java.util.Set groupIds = identityStoreService.listMembershipsForScim(identityStoreId).stream() + .filter(membership -> memberId.equals(membership.userId())) + .map(Membership::groupId) + .collect(java.util.stream.Collectors.toSet()); + return groups.stream().filter(group -> groupIds.contains(group.groupId())).toList(); + } + + private void requireScimUser(String identityStoreId, String userId) { + ObjectNode request = mapper.createObjectNode(); + request.put("IdentityStoreId", identityStoreId); + request.put("UserId", userId); + identityStoreService.describeUser(request); + } + + private static int scimCount(String countValue) { + if (countValue == null) { + return 100; + } + try { + int count = Integer.parseInt(countValue); + if (count < 1 || count > 100) { + throw validation("count must be between 1 and 100."); + } + return count; + } catch (NumberFormatException exception) { + throw validation("count must be between 1 and 100."); + } + } + + private static String encodeCursor(int offset, String filter) { + String fingerprint = filter == null ? "" : filter; + return Base64.getUrlEncoder().withoutPadding().encodeToString( + (offset + "\n" + fingerprint).getBytes(StandardCharsets.UTF_8)); + } + + private static int decodeCursor(String cursor, String filter) { + if (cursor == null || cursor.isEmpty()) { + return 0; + } + if (!CURSOR_PATTERN.matcher(cursor).matches()) { + throw validation("cursor is invalid."); + } + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + int separator = decoded.indexOf('\n'); + if (separator <= 0) { + throw validation("cursor is invalid."); + } + int offset = Integer.parseInt(decoded.substring(0, separator)); + String expectedFilter = filter == null ? "" : filter; + if (offset < 0 || !expectedFilter.equals(decoded.substring(separator + 1))) { + throw validation("cursor is invalid or filter parameters changed between pagination requests."); + } + return offset; + } catch (IllegalArgumentException exception) { + throw validation("cursor is invalid."); + } + } + + private static void validateListQueryParameters(UriInfo uriInfo, List allowed) { + for (String parameter : uriInfo.getQueryParameters().keySet()) { + if (!allowed.contains(parameter)) { + throw validation("Unsupported query parameter: " + parameter); + } + } + } + private static String requiredText(JsonNode node, String field) { String value = optionalText(node, field); if (value == null || value.isBlank()) { diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 2b15e2ff44..0c91228393 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -318,4 +318,95 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .statusCode(400) .body("status", equalTo("400")); } + + @Test + void listGroupsSupportsAwsFiltersAndCursorPagination() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"list-groups@example.com\",\"displayName\":\"List Groups User\"," + + "\"name\":{\"givenName\":\"List\",\"familyName\":\"Groups\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"list-groups-ext\",\"displayName\":\"SCIM List Groups Target\"," + + "\"members\":[{\"value\":\"" + userId + "\",\"type\":\"User\"}]}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "displayName eq \"SCIM List Groups Target\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:api:messages:2.0:ListResponse")) + .body("totalResults", equalTo(1)) + .body("itemsPerPage", equalTo(1)) + .body("startIndex", equalTo(1)) + .body("Resources[0].id", equalTo(groupId)); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "members.value eq \"" + userId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("Resources.id", hasItem(groupId)); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "id eq \"" + groupId + "\" and member eq \"" + userId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("totalResults", equalTo(1)) + .body("Resources[0].id", equalTo(groupId)); + + String nextCursor = given() + .header("Authorization", BEARER) + .queryParam("cursor", "") + .queryParam("count", 1) + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("itemsPerPage", equalTo(1)) + .body("totalResults", org.hamcrest.Matchers.nullValue()) + .body("startIndex", org.hamcrest.Matchers.nullValue()) + .body("nextCursor", notNullValue()) + .extract().path("nextCursor"); + + given() + .header("Authorization", BEARER) + .queryParam("cursor", nextCursor) + .queryParam("count", 1) + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("itemsPerPage", equalTo(1)); + + given() + .header("Authorization", BEARER) + .queryParam("cursor", nextCursor) + .queryParam("filter", "displayName eq \"changed\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(400) + .body("status", equalTo("400")); + } } From 58857e3fd02780f93cd359bbb74ed906666ea78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:40:14 -0400 Subject: [PATCH 38/99] feat(scim): list users --- docs/services/identitystore.md | 2 + .../identitystore/IdentityStoreService.java | 4 + .../identitystore/ScimController.java | 106 ++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 92 +++++++++++++++ 4 files changed, 204 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index cea1bb3c08..37bce7dbba 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -60,6 +60,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `ListGroups` is supported through `GET /{tenant_id}/scim/v2/Groups`. It implements IAM Identity Center's `displayName`, `externalId`, `members.value`, `id`, and combined `id`/`member` equality filters, `count` from 1 through 100, and the AWS cursor pagination contract. The first cursor request uses an empty `cursor`; subsequent cursors preserve the original filter and reject filter changes. Non-paginated responses contain `totalResults`, `startIndex`, and `itemsPerPage`, while cursor responses use `itemsPerPage` and `nextCursor`. Group resources expose an empty `members` list, matching the IAM Identity Center SCIM limitation. +`ListUsers` is supported through `GET /{tenant_id}/scim/v2/Users`. It implements the AWS-supported `userName`, `externalId`, `groups.value`, `id`, and combined `id`/`manager` equality filters, the same 1-100 `count` limit, and IAM Identity Center cursor pagination semantics including filter immutability between pages. The unsupported SCIM query parameters `startIndex`, `attributes`, and `excludedAttributes` are rejected. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index d646db8ee2..d3cfa37212 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java @@ -177,6 +177,10 @@ public PaginatedResult listUsers(JsonNode request) { optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + List listUsersForScim(String storeId) { + return listUsersAll(requireStore(storeId), null); + } + public User describeUser(JsonNode request) { String storeId = requireStore(required(request, "IdentityStoreId")); return requireUser(storeId, requireResourceId(required(request, "UserId"), "UserId")); diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 108d9b58cb..6aea515a6e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -49,6 +49,10 @@ public class ScimController { "^(displayName|externalId|members\\.value|id) eq \\\"([^\\\"]*)\\\"$"); private static final Pattern DOUBLE_GROUP_FILTER = Pattern.compile( "^(id|member) eq \\\"([^\\\"]*)\\\" and (id|member) eq \\\"([^\\\"]*)\\\"$"); + private static final Pattern SINGLE_USER_FILTER = Pattern.compile( + "^(userName|externalId|groups\\.value|id) eq \\\"([^\\\"]*)\\\"$"); + private static final Pattern DOUBLE_USER_FILTER = Pattern.compile( + "^(id|manager) eq \\\"([^\\\"]*)\\\" and (id|manager) eq \\\"([^\\\"]*)\\\"$"); private static final Pattern PREFIXED_TENANT = Pattern.compile( "([0-9a-f]{10})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); @@ -67,6 +71,48 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/Users") + public Response listUsers(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization, + @QueryParam("filter") String filter, + @QueryParam("count") String countValue, + @QueryParam("cursor") String cursor, + @Context UriInfo uriInfo) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + validateListQueryParameters(uriInfo, List.of("filter", "count", "cursor")); + int count = scimCount(countValue); + boolean cursorPresent = uriInfo.getQueryParameters().containsKey("cursor"); + List matching = filterScimUsers(identityStoreId, filter); + int offset = cursorPresent ? decodeCursor(cursor, filter) : 0; + if (offset > matching.size()) { + throw validation("cursor is invalid."); + } + int end = Math.min(offset + count, matching.size()); + + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(LIST_SCHEMA); + ArrayNode resources = response.putArray("Resources"); + for (User user : matching.subList(offset, end)) { + resources.add(userResponse(user)); + } + response.put("itemsPerPage", end - offset); + if (cursorPresent) { + if (end < matching.size()) { + response.put("nextCursor", encodeCursor(end, filter)); + } + } else { + response.put("totalResults", matching.size()); + response.put("startIndex", 1); + } + return Response.ok(response).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Users/{userId}") public Response getUser(@PathParam("tenantId") String tenantId, @@ -546,6 +592,66 @@ private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sour } } + private List filterScimUsers(String identityStoreId, String filter) { + List users = identityStoreService.listUsersForScim(identityStoreId); + if (filter == null || filter.isBlank()) { + if (filter != null && !filter.isEmpty()) { + throw validation("filter is invalid."); + } + return users; + } + + Matcher single = SINGLE_USER_FILTER.matcher(filter); + if (single.matches()) { + String attribute = single.group(1); + String value = single.group(2); + return switch (attribute) { + case "userName" -> users.stream() + .filter(user -> value.equals(user.userName())) + .toList(); + case "externalId" -> users.stream() + .filter(user -> value.equals(scimExternalId(user.attributes().get("ExternalIds"), USER_SCHEMA))) + .toList(); + case "id" -> users.stream() + .filter(user -> value.equals(user.userId())) + .toList(); + case "groups.value" -> usersForGroup(identityStoreId, users, value); + default -> throw validation("filter is invalid."); + }; + } + + Matcher combined = DOUBLE_USER_FILTER.matcher(filter); + if (!combined.matches() || combined.group(1).equals(combined.group(3))) { + throw validation("filter is invalid."); + } + String userId = "id".equals(combined.group(1)) ? combined.group(2) : combined.group(4); + String managerId = "manager".equals(combined.group(1)) ? combined.group(2) : combined.group(4); + return users.stream() + .filter(user -> userId.equals(user.userId()) && managerId.equals(scimManagerId(user))) + .toList(); + } + + private List usersForGroup(String identityStoreId, List users, String groupId) { + java.util.Set userIds = identityStoreService.listMembershipsForScim(identityStoreId).stream() + .filter(membership -> groupId.equals(membership.groupId())) + .map(Membership::userId) + .collect(java.util.stream.Collectors.toSet()); + return users.stream().filter(user -> userIds.contains(user.userId())).toList(); + } + + private static String scimManagerId(User user) { + JsonNode extensions = user.attributes().get("Extensions"); + if (extensions == null || !extensions.isObject()) { + return null; + } + JsonNode enterprise = extensions.get(IDENTITYSTORE_ENTERPRISE_EXTENSION); + if (enterprise == null || !enterprise.isObject()) { + return null; + } + JsonNode manager = enterprise.get("manager"); + return manager != null && manager.isObject() ? optionalText(manager, "value") : null; + } + private List filterScimGroups(String identityStoreId, String filter) { List groups = identityStoreService.listGroupsForScim(identityStoreId); if (filter == null || filter.isBlank()) { diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 0c91228393..3ed0112733 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,98 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void listUsersSupportsAwsFiltersAndCursorPagination() { + String managerId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"manager-list@example.com\",\"displayName\":\"List Manager\"," + + "\"name\":{\"givenName\":\"List\",\"familyName\":\"Manager\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"list-users-ext\",\"userName\":\"list-users@example.com\"," + + "\"displayName\":\"SCIM List Users Target\"," + + "\"name\":{\"givenName\":\"List\",\"familyName\":\"Users\"}," + + "\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\":{" + + "\"manager\":{\"value\":\"" + managerId + "\"}}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"displayName\":\"SCIM List Users Group\",\"members\":[{\"value\":\"" + + userId + "\",\"type\":\"User\"}]}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "userName eq \"list-users@example.com\"") + .when() + .get("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(200) + .body("totalResults", equalTo(1)) + .body("Resources[0].id", equalTo(userId)); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "groups.value eq \"" + groupId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(200) + .body("Resources.id", hasItem(userId)); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "id eq \"" + userId + "\" and manager eq \"" + managerId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(200) + .body("totalResults", equalTo(1)) + .body("Resources[0].id", equalTo(userId)); + + String nextCursor = given() + .header("Authorization", BEARER) + .queryParam("cursor", "") + .queryParam("count", 1) + .when() + .get("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(200) + .body("itemsPerPage", equalTo(1)) + .body("totalResults", org.hamcrest.Matchers.nullValue()) + .body("startIndex", org.hamcrest.Matchers.nullValue()) + .body("nextCursor", notNullValue()) + .extract().path("nextCursor"); + + given() + .header("Authorization", BEARER) + .queryParam("cursor", nextCursor) + .queryParam("filter", "userName eq \"changed@example.com\"") + .when() + .get("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(400) + .body("status", equalTo("400")); + } + @Test void listGroupsSupportsAwsFiltersAndCursorPagination() { String userId = given() From b468e7d9f25881fa69e8183137d0693754672955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:42:51 -0400 Subject: [PATCH 39/99] feat(scim): get schemas --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 100 ++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 23 ++++ 3 files changed, 125 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 37bce7dbba..e9621210ce 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -62,6 +62,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `ListUsers` is supported through `GET /{tenant_id}/scim/v2/Users`. It implements the AWS-supported `userName`, `externalId`, `groups.value`, `id`, and combined `id`/`manager` equality filters, the same 1-100 `count` limit, and IAM Identity Center cursor pagination semantics including filter immutability between pages. The unsupported SCIM query parameters `startIndex`, `attributes`, and `excludedAttributes` are rejected. +`GetSchema` is supported through `GET /{tenant_id}/scim/v2/Schemas/{schemaId}` for the IAM Identity Center User, Group, and Enterprise User schemas. The returned schema metadata reflects the writable/read-only characteristics used by the emulator's SCIM implementation and the AWS-supported user/group attributes. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 6aea515a6e..39e5405b9c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -71,6 +71,24 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/Schemas/{schemaId}") + public Response getSchema(@PathParam("tenantId") String tenantId, + @PathParam("schemaId") String schemaId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + resolveIdentityStore(tenantId); + ObjectNode schema = scimSchema(schemaId); + if (schema == null) { + return scimError(404, "Schema not found: " + schemaId); + } + return Response.ok(schema).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Users") public Response listUsers(@PathParam("tenantId") String tenantId, @@ -592,6 +610,88 @@ private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sour } } + private ObjectNode scimSchema(String schemaId) { + return switch (schemaId) { + case USER_SCHEMA -> userSchema(); + case GROUP_SCHEMA -> groupSchema(); + case ENTERPRISE_USER_SCHEMA -> enterpriseUserSchema(); + default -> null; + }; + } + + private ObjectNode userSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("id", USER_SCHEMA); + schema.put("name", "User"); + schema.put("description", "User Schema"); + ArrayNode attributes = schema.putArray("attributes"); + attributes.add(scimAttribute("userName", "string", false, true, "readWrite", "default", "server")); + + ObjectNode name = scimAttribute("name", "complex", false, false, "readWrite", "default", "none"); + ArrayNode subAttributes = name.putArray("subAttributes"); + subAttributes.add(scimAttribute("formatted", "string", false, false, "readWrite", "default", "none")); + subAttributes.add(scimAttribute("familyName", "string", false, true, "readWrite", "default", "none")); + subAttributes.add(scimAttribute("givenName", "string", false, true, "readWrite", "default", "none")); + subAttributes.add(scimAttribute("middleName", "string", false, false, "readWrite", "default", "none")); + subAttributes.add(scimAttribute("honorificPrefix", "string", false, false, "readWrite", "default", "none")); + subAttributes.add(scimAttribute("honorificSuffix", "string", false, false, "readWrite", "default", "none")); + attributes.add(name); + + attributes.add(scimAttribute("displayName", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("nickName", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("profileUrl", "reference", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("title", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("userType", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("preferredLanguage", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("locale", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("timezone", "string", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("active", "boolean", false, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("emails", "complex", true, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("phoneNumbers", "complex", true, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("addresses", "complex", true, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("roles", "complex", true, false, "readWrite", "default", "none")); + attributes.add(scimAttribute("groups", "complex", true, false, "readOnly", "default", "none")); + return schema; + } + + private ObjectNode groupSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("id", GROUP_SCHEMA); + schema.put("name", "Group"); + schema.put("description", "Group"); + ArrayNode attributes = schema.putArray("attributes"); + attributes.add(scimAttribute("displayName", "string", false, true, "readWrite", "default", "server")); + attributes.add(scimAttribute("members", "complex", true, false, "readWrite", "default", "none")); + return schema; + } + + private ObjectNode enterpriseUserSchema() { + ObjectNode schema = mapper.createObjectNode(); + schema.put("id", ENTERPRISE_USER_SCHEMA); + schema.put("name", "EnterpriseUser"); + schema.put("description", "Enterprise User"); + ArrayNode attributes = schema.putArray("attributes"); + for (String name : List.of("employeeNumber", "costCenter", "organization", "division", "department")) { + attributes.add(scimAttribute(name, "string", false, false, "readWrite", "default", "none")); + } + attributes.add(scimAttribute("manager", "complex", false, false, "readWrite", "default", "none")); + return schema; + } + + private ObjectNode scimAttribute(String name, String type, boolean multiValued, boolean required, + String mutability, String returned, String uniqueness) { + ObjectNode attribute = mapper.createObjectNode(); + attribute.put("name", name); + attribute.put("type", type); + attribute.put("multiValued", multiValued); + attribute.put("required", required); + attribute.put("caseExact", false); + attribute.put("mutability", mutability); + attribute.put("returned", returned); + attribute.put("uniqueness", uniqueness); + return attribute; + } + private List filterScimUsers(String identityStoreId, String filter) { List users = identityStoreService.listUsersForScim(identityStoreId); if (filter == null || filter.isBlank()) { diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 3ed0112733..1fcde71764 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,29 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void getSchemaReturnsAwsScimSchema() { + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User") + .then() + .statusCode(200) + .body("id", equalTo("urn:ietf:params:scim:schemas:core:2.0:User")) + .body("name", equalTo("User")) + .body("description", equalTo("User Schema")) + .body("attributes.name", hasItem("userName")) + .body("attributes.find { it.name == 'userName' }.required", equalTo(true)); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Schemas/urn:example:missing") + .then() + .statusCode(404) + .body("status", equalTo("404")); + } + @Test void listUsersSupportsAwsFiltersAndCursorPagination() { String managerId = given() From adfe1240460dffddf78238d6ecee61e2b89e0c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:44:41 -0400 Subject: [PATCH 40/99] feat(scim): list schemas --- docs/services/identitystore.md | 2 ++ .../identitystore/ScimController.java | 22 +++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 17 ++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index e9621210ce..80e71c1e12 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -64,6 +64,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `GetSchema` is supported through `GET /{tenant_id}/scim/v2/Schemas/{schemaId}` for the IAM Identity Center User, Group, and Enterprise User schemas. The returned schema metadata reflects the writable/read-only characteristics used by the emulator's SCIM implementation and the AWS-supported user/group attributes. +`ListSchemas` is supported through `GET /{tenant_id}/scim/v2/Schemas` and returns the IAM Identity Center SCIM list response with the three supported schema documents: User, Enterprise User, and Group. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 39e5405b9c..af3f9a2c9a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -71,6 +71,28 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/Schemas") + public Response listSchemas(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + resolveIdentityStore(tenantId); + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(LIST_SCHEMA); + response.put("totalResults", 3); + response.put("itemsPerPage", 3); + response.put("startIndex", 1); + ArrayNode resources = response.putArray("Resources"); + resources.add(userSchema()); + resources.add(enterpriseUserSchema()); + resources.add(groupSchema()); + return Response.ok(response).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Schemas/{schemaId}") public Response getSchema(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 1fcde71764..7874472498 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,23 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void listSchemasReturnsAwsScimCatalog() { + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Schemas") + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:api:messages:2.0:ListResponse")) + .body("totalResults", equalTo(3)) + .body("itemsPerPage", equalTo(3)) + .body("startIndex", equalTo(1)) + .body("Resources.id", hasItem("urn:ietf:params:scim:schemas:core:2.0:User")) + .body("Resources.id", hasItem("urn:ietf:params:scim:schemas:core:2.0:Group")) + .body("Resources.id", hasItem("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")); + } + @Test void getSchemaReturnsAwsScimSchema() { given() From ecce2da8cd6340a6820ad6628f0adb058f2a287c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:46:53 -0400 Subject: [PATCH 41/99] feat(scim): list resource types --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 44 +++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 23 ++++++++++ 3 files changed, 69 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 80e71c1e12..6f4a615a4a 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -66,6 +66,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `ListSchemas` is supported through `GET /{tenant_id}/scim/v2/Schemas` and returns the IAM Identity Center SCIM list response with the three supported schema documents: User, Enterprise User, and Group. +`ListResourceTypes` is supported through `GET /{tenant_id}/scim/v2/ResourceTypes`. It returns the AWS SCIM User and Group resource type descriptors, including `/Users` and `/Groups` endpoints, core schema URNs, the required Enterprise User schema extension for User, and resource metadata locations. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index af3f9a2c9a..6e6cec6e92 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -44,6 +44,7 @@ public class ScimController { private static final String IDENTITYSTORE_ENTERPRISE_EXTENSION = "aws:identitystore:enterprise"; private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; private static final String LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + private static final String RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; private static final Pattern CURSOR_PATTERN = Pattern.compile("[-a-zA-Z0-9+=/:_]*"); private static final Pattern SINGLE_GROUP_FILTER = Pattern.compile( "^(displayName|externalId|members\\.value|id) eq \\\"([^\\\"]*)\\\"$"); @@ -71,6 +72,28 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/ResourceTypes") + public Response listResourceTypes(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization, + @Context UriInfo uriInfo) { + try { + requireBearer(authorization); + resolveIdentityStore(tenantId); + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(LIST_SCHEMA); + response.put("totalResults", 2); + response.put("itemsPerPage", 2); + response.put("startIndex", 1); + ArrayNode resources = response.putArray("Resources"); + resources.add(resourceType("User", "/Users", "User Account", USER_SCHEMA, true, tenantId, uriInfo)); + resources.add(resourceType("Group", "/Groups", "Group", GROUP_SCHEMA, false, tenantId, uriInfo)); + return Response.ok(response).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/Schemas") public Response listSchemas(@PathParam("tenantId") String tenantId, @@ -632,6 +655,27 @@ private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sour } } + private ObjectNode resourceType(String id, String endpoint, String description, String schemaId, + boolean enterpriseExtension, String tenantId, UriInfo uriInfo) { + ObjectNode resourceType = mapper.createObjectNode(); + resourceType.putArray("schemas").add(RESOURCE_TYPE_SCHEMA); + resourceType.put("id", id); + resourceType.put("name", id); + resourceType.put("endpoint", endpoint); + resourceType.put("description", description); + resourceType.put("schema", schemaId); + if (enterpriseExtension) { + ObjectNode extension = resourceType.putArray("schemaExtensions").addObject(); + extension.put("schema", ENTERPRISE_USER_SCHEMA); + extension.put("required", true); + } + ObjectNode meta = resourceType.putObject("meta"); + meta.put("resourceType", "ResourceType"); + meta.put("location", uriInfo.getBaseUriBuilder() + .path(tenantId).path("scim").path("v2").path("ResourceTypes").path(id).build().toString()); + return resourceType; + } + private ObjectNode scimSchema(String schemaId) { return switch (schemaId) { case USER_SCHEMA -> userSchema(); diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 7874472498..46f761460e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,29 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void listResourceTypesReturnsAwsScimCatalog() { + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/ResourceTypes") + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:api:messages:2.0:ListResponse")) + .body("totalResults", equalTo(2)) + .body("itemsPerPage", equalTo(2)) + .body("startIndex", equalTo(1)) + .body("Resources.id", hasItem("User")) + .body("Resources.id", hasItem("Group")) + .body("Resources.find { it.id == 'User' }.endpoint", equalTo("/Users")) + .body("Resources.find { it.id == 'User' }.schema", equalTo("urn:ietf:params:scim:schemas:core:2.0:User")) + .body("Resources.find { it.id == 'User' }.schemaExtensions[0].schema", + equalTo("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")) + .body("Resources.find { it.id == 'User' }.schemaExtensions[0].required", equalTo(true)) + .body("Resources.find { it.id == 'User' }.meta.resourceType", equalTo("ResourceType")) + .body("Resources.find { it.id == 'User' }.meta.location", notNullValue()); + } + @Test void listSchemasReturnsAwsScimCatalog() { given() From cfc20b1032fd922be1788cccefc031f78d70956e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:49:08 -0400 Subject: [PATCH 42/99] feat(scim): add service provider config --- docs/services/identitystore.md | 2 ++ .../identitystore/ScimController.java | 35 +++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 22 ++++++++++++ 3 files changed, 59 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 6f4a615a4a..cca6de4bbb 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -68,6 +68,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `ListResourceTypes` is supported through `GET /{tenant_id}/scim/v2/ResourceTypes`. It returns the AWS SCIM User and Group resource type descriptors, including `/Users` and `/Groups` endpoints, core schema URNs, the required Enterprise User schema extension for User, and resource metadata locations. +`ServiceProviderConfig` is supported through `GET /{tenant_id}/scim/v2/ServiceProviderConfig`. It returns IAM Identity Center's documented OAuth bearer authentication scheme and capability flags: patch supported, bulk/change-password/sort/etag unsupported, bulk limits of one operation and 1 MiB, and filter support with `maxResults` 50. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 6e6cec6e92..c848ed7704 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -45,6 +45,7 @@ public class ScimController { private static final String ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"; private static final String LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; private static final String RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; + private static final String SERVICE_PROVIDER_CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; private static final Pattern CURSOR_PATTERN = Pattern.compile("[-a-zA-Z0-9+=/:_]*"); private static final Pattern SINGLE_GROUP_FILTER = Pattern.compile( "^(displayName|externalId|members\\.value|id) eq \\\"([^\\\"]*)\\\"$"); @@ -72,6 +73,40 @@ public ScimController(IdentityStoreService identityStoreService, SsoAdminService this.mapper = mapper; } + @GET + @Path("/ServiceProviderConfig") + public Response serviceProviderConfig(@PathParam("tenantId") String tenantId, + @HeaderParam("Authorization") String authorization) { + try { + requireBearer(authorization); + resolveIdentityStore(tenantId); + ObjectNode response = mapper.createObjectNode(); + response.putArray("schemas").add(SERVICE_PROVIDER_CONFIG_SCHEMA); + response.put("documentationUri", "https://docs.aws.amazon.com/singlesignon/latest/userguide/manage-your-identity-source-idp.html"); + ObjectNode auth = response.putArray("authenticationSchemes").addObject(); + auth.put("type", "oauthbearertoken"); + auth.put("name", "OAuth Bearer Token"); + auth.put("description", "Authentication scheme using the OAuth Bearer Token Standard"); + auth.put("specUri", "https://www.rfc-editor.org/info/rfc6750"); + auth.put("documentationUri", "https://docs.aws.amazon.com/singlesignon/latest/userguide/provision-automatically.html"); + auth.put("primary", true); + response.putObject("patch").put("supported", true); + ObjectNode bulk = response.putObject("bulk"); + bulk.put("supported", false); + bulk.put("maxOperations", 1); + bulk.put("maxPayloadSize", 1048576); + ObjectNode filter = response.putObject("filter"); + filter.put("supported", true); + filter.put("maxResults", 50); + response.putObject("changePassword").put("supported", false); + response.putObject("sort").put("supported", false); + response.putObject("etag").put("supported", false); + return Response.ok(response).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @GET @Path("/ResourceTypes") public Response listResourceTypes(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 46f761460e..ec94aed7f6 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,28 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void serviceProviderConfigMatchesAwsScimCapabilities() { + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/ServiceProviderConfig") + .then() + .statusCode(200) + .body("schemas[0]", equalTo("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")) + .body("authenticationSchemes[0].type", equalTo("oauthbearertoken")) + .body("authenticationSchemes[0].primary", equalTo(true)) + .body("patch.supported", equalTo(true)) + .body("bulk.supported", equalTo(false)) + .body("bulk.maxOperations", equalTo(1)) + .body("bulk.maxPayloadSize", equalTo(1048576)) + .body("filter.supported", equalTo(true)) + .body("filter.maxResults", equalTo(50)) + .body("changePassword.supported", equalTo(false)) + .body("sort.supported", equalTo(false)) + .body("etag.supported", equalTo(false)); + } + @Test void listResourceTypesReturnsAwsScimCatalog() { given() From 6e817ef384911f80305eadcbecede5b64c388572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 13:53:42 -0400 Subject: [PATCH 43/99] feat(scim): patch groups --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 149 ++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 109 +++++++++++++ 3 files changed, 260 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index cca6de4bbb..2c92fa2a62 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -70,6 +70,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `ServiceProviderConfig` is supported through `GET /{tenant_id}/scim/v2/ServiceProviderConfig`. It returns IAM Identity Center's documented OAuth bearer authentication scheme and capability flags: patch supported, bulk/change-password/sort/etag unsupported, bulk limits of one operation and 1 MiB, and filter support with `maxResults` 50. +`PatchGroup` is supported through `PATCH /{tenant_id}/scim/v2/Groups/{id}` with the SCIM PatchOp schema. It allows only `displayName`, `externalId`, and `members`; member additions/removals are validated before mutation, capped at 100 changes per request, and share the Identity Store membership state. Replacing all memberships or removing them with an empty value is rejected with HTTP `400`, matching IAM Identity Center. Successful patches return HTTP `204` with an empty body. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index c848ed7704..f293db7923 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -16,6 +16,7 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -46,6 +47,7 @@ public class ScimController { private static final String LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; private static final String RESOURCE_TYPE_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; private static final String SERVICE_PROVIDER_CONFIG_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; + private static final String PATCH_OP_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; private static final Pattern CURSOR_PATTERN = Pattern.compile("[-a-zA-Z0-9+=/:_]*"); private static final Pattern SINGLE_GROUP_FILTER = Pattern.compile( "^(displayName|externalId|members\\.value|id) eq \\\"([^\\\"]*)\\\"$"); @@ -305,6 +307,25 @@ public Response deleteUser(@PathParam("tenantId") String tenantId, } } + @PATCH + @Path("/Groups/{groupId}") + public Response patchGroup(@PathParam("tenantId") String tenantId, + @PathParam("groupId") String groupId, + @HeaderParam("Authorization") String authorization, + String body) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + JsonNode request = parseObject(body); + validatePatchSchema(request); + validatePatchGroup(identityStoreId, groupId, request); + applyPatchGroup(identityStoreId, groupId, request); + return Response.noContent().build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @DELETE @Path("/Groups/{groupId}") public Response deleteGroup(@PathParam("tenantId") String tenantId, @@ -690,6 +711,134 @@ private void copyObjectArrayBack(JsonNode source, ObjectNode target, String sour } } + private void validatePatchSchema(JsonNode request) { + JsonNode schemas = request.get("schemas"); + if (schemas == null || !schemas.isArray() || schemas.size() != 1 + || !PATCH_OP_SCHEMA.equals(schemas.get(0).textValue())) { + throw validation("schemas must contain only the SCIM PatchOp schema."); + } + } + + private void validatePatchGroup(String identityStoreId, String groupId, JsonNode request) { + ObjectNode describe = mapper.createObjectNode(); + describe.put("IdentityStoreId", identityStoreId); + describe.put("GroupId", groupId); + identityStoreService.describeGroup(describe); + + JsonNode operations = request.get("Operations"); + if (operations == null || !operations.isArray() || operations.isEmpty()) { + throw validation("Operations must contain at least one patch operation."); + } + int membershipChanges = 0; + for (JsonNode operation : operations) { + if (operation == null || !operation.isObject()) { + throw validation("Each patch operation must be an object."); + } + String op = requiredText(operation, "op"); + String path = requiredText(operation, "path"); + if (!List.of("add", "replace", "remove").contains(op)) { + throw validation("Unsupported patch operation: " + op); + } + if (!List.of("displayName", "members", "externalId").contains(path)) { + throw validation("Only displayName, members, and externalId can be patched on groups."); + } + JsonNode value = operation.get("value"); + if ("members".equals(path)) { + if ("replace".equals(op)) { + throw validation("Replacing all group memberships in one request is not supported."); + } + if (value == null || !value.isArray() || value.isEmpty()) { + throw validation("members patch value must contain at least one member."); + } + membershipChanges += value.size(); + if (membershipChanges > 100) { + throw validation("A maximum of 100 membership changes are allowed in one request."); + } + for (JsonNode member : value) { + if (member == null || !member.isObject()) { + throw validation("Each members value must be an object."); + } + String userId = requiredText(member, "value"); + String type = optionalText(member, "type"); + if (type != null && !"User".equals(type)) { + throw validation("members.type must be User when specified."); + } + requireScimUser(identityStoreId, userId); + } + continue; + } + if ("displayName".equals(path)) { + if ("remove".equals(op)) { + throw validation("displayName cannot be removed."); + } + String displayName = requirePatchText(value, "displayName"); + boolean duplicate = identityStoreService.listGroupsForScim(identityStoreId).stream() + .anyMatch(group -> displayName.equals(group.displayName()) && !groupId.equals(group.groupId())); + if (duplicate) { + throw new AwsException("ConflictException", "A group with DisplayName " + displayName + " already exists.", 400); + } + continue; + } + if (!"remove".equals(op)) { + requirePatchText(value, "externalId"); + } + } + } + + private void applyPatchGroup(String identityStoreId, String groupId, JsonNode request) { + for (JsonNode operation : request.get("Operations")) { + String op = operation.path("op").textValue(); + String path = operation.path("path").textValue(); + if ("members".equals(path)) { + for (JsonNode member : operation.get("value")) { + String userId = member.path("value").textValue(); + Membership existing = identityStoreService.listMembershipsForScim(identityStoreId).stream() + .filter(candidate -> groupId.equals(candidate.groupId()) && userId.equals(candidate.userId())) + .findFirst().orElse(null); + if ("add".equals(op) && existing == null) { + ObjectNode membership = mapper.createObjectNode(); + membership.put("IdentityStoreId", identityStoreId); + membership.put("GroupId", groupId); + membership.putObject("MemberId").put("UserId", userId); + identityStoreService.createMembership(membership); + } else if ("remove".equals(op) && existing != null) { + ObjectNode deletion = mapper.createObjectNode(); + deletion.put("IdentityStoreId", identityStoreId); + deletion.put("MembershipId", existing.membershipId()); + identityStoreService.deleteMembership(deletion); + } + } + continue; + } + + ObjectNode update = mapper.createObjectNode(); + update.put("IdentityStoreId", identityStoreId); + update.put("GroupId", groupId); + ObjectNode attributeOperation = update.putArray("Operations").addObject(); + if ("displayName".equals(path)) { + attributeOperation.put("AttributePath", "displayName"); + attributeOperation.set("AttributeValue", operation.get("value").deepCopy()); + } else { + attributeOperation.put("AttributePath", "externalIds"); + if ("remove".equals(op)) { + attributeOperation.putNull("AttributeValue"); + } else { + ArrayNode externalIds = mapper.createArrayNode(); + externalIds.addObject().put("Issuer", GROUP_SCHEMA).put("Id", operation.get("value").textValue()); + attributeOperation.set("AttributeValue", externalIds); + } + } + identityStoreService.updateGroup(update); + } + } + + private static String requirePatchText(JsonNode value, String field) { + if (value == null || !value.isTextual() || value.textValue().isBlank()) { + throw validation(field + " patch value must be a non-empty string."); + } + return value.textValue(); + } + private ObjectNode resourceType(String id, String endpoint, String description, String schemaId, boolean enterpriseExtension, String tenantId, UriInfo uriInfo) { ObjectNode resourceType = mapper.createObjectNode(); diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index ec94aed7f6..d1f3a0b438 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,115 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void patchGroupUpdatesAttributesAndMemberships() { + String firstUserId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"patch-group-one@example.com\",\"displayName\":\"Patch One\"," + + "\"name\":{\"givenName\":\"Patch\",\"familyName\":\"One\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + String secondUserId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"patch-group-two@example.com\",\"displayName\":\"Patch Two\"," + + "\"name\":{\"givenName\":\"Patch\",\"familyName\":\"Two\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"patch-group-old\",\"displayName\":\"Patch Group Old\"," + + "\"members\":[{\"value\":\"" + firstUserId + "\"}]}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[" + + "{\"op\":\"replace\",\"path\":\"displayName\",\"value\":\"Patch Group New\"}," + + "{\"op\":\"replace\",\"path\":\"externalId\",\"value\":\"patch-group-new\"}," + + "{\"op\":\"add\",\"path\":\"members\",\"value\":[{\"value\":\"" + secondUserId + "\"}]}," + + "{\"op\":\"remove\",\"path\":\"members\",\"value\":[{\"value\":\"" + firstUserId + "\"}]}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(204); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(200) + .body("displayName", equalTo("Patch Group New")) + .body("externalId", equalTo("patch-group-new")); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "members.value eq \"" + secondUserId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("Resources.id", hasItem(groupId)); + + given() + .header("Authorization", BEARER) + .queryParam("filter", "members.value eq \"" + firstUserId + "\"") + .when() + .get("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(200) + .body("totalResults", equalTo(0)); + } + + @Test + void patchGroupRejectsUnsupportedOrBulkMembershipChanges() { + String groupId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"displayName\":\"Patch Group Validation\"}") + .when() + .post("/" + TENANT + "/scim/v2/Groups") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"]," + + "\"Operations\":[{\"op\":\"remove\",\"path\":\"members\",\"value\":[]}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"]," + + "\"Operations\":[{\"op\":\"replace\",\"path\":\"description\",\"value\":\"nope\"}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Groups/" + groupId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + } + @Test void serviceProviderConfigMatchesAwsScimCapabilities() { given() From c74228c9c897e30104ad2a3e9cb243007e6f493b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 14:26:33 -0400 Subject: [PATCH 44/99] feat(scim): patch users --- docs/services/identitystore.md | 2 + .../identitystore/ScimController.java | 258 ++++++++++++++++++ .../identitystore/ScimIntegrationTest.java | 83 ++++++ 3 files changed, 343 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 2c92fa2a62..2a24a51e04 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -72,6 +72,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `PatchGroup` is supported through `PATCH /{tenant_id}/scim/v2/Groups/{id}` with the SCIM PatchOp schema. It allows only `displayName`, `externalId`, and `members`; member additions/removals are validated before mutation, capped at 100 changes per request, and share the Identity Store membership state. Replacing all memberships or removing them with an empty value is rejected with HTTP `400`, matching IAM Identity Center. Successful patches return HTTP `204` with an empty body. +`PatchUser` is supported through `PATCH /{tenant_id}/scim/v2/Users/{id}` with `add`, `replace`, and `remove` PatchOp operations. It supports the IAM Identity Center user attribute set, pathless object values for add/replace, textual or JSON boolean values for `active`, single-value email/address/phone constraints, enterprise/name updates, conflict detection for `userName`, and the AWS restriction against multiple `userName` or `active` changes in one request. Successful patches return HTTP `200` with the updated SCIM user representation. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index f293db7923..8b27c6f61e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -57,6 +57,9 @@ public class ScimController { "^(userName|externalId|groups\\.value|id) eq \\\"([^\\\"]*)\\\"$"); private static final Pattern DOUBLE_USER_FILTER = Pattern.compile( "^(id|manager) eq \\\"([^\\\"]*)\\\" and (id|manager) eq \\\"([^\\\"]*)\\\"$"); + private static final List PATCH_USER_ATTRIBUTES = List.of( + "userName", "active", "externalId", "displayName", "nickName", "profileUrl", "title", "userType", + "preferredLanguage", "locale", "timezone", "name", "enterprise", "emails", "addresses", "phoneNumbers"); private static final Pattern PREFIXED_TENANT = Pattern.compile( "([0-9a-f]{10})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", Pattern.CASE_INSENSITIVE); @@ -307,6 +310,28 @@ public Response deleteUser(@PathParam("tenantId") String tenantId, } } + @PATCH + @Path("/Users/{userId}") + public Response patchUser(@PathParam("tenantId") String tenantId, + @PathParam("userId") String userId, + @HeaderParam("Authorization") String authorization, + String body) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + JsonNode request = parseObject(body); + validatePatchSchema(request); + validatePatchUser(identityStoreId, userId, request); + applyPatchUser(identityStoreId, userId, request); + ObjectNode describe = mapper.createObjectNode(); + describe.put("IdentityStoreId", identityStoreId); + describe.put("UserId", userId); + return Response.ok(userResponse(identityStoreService.describeUser(describe))).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @PATCH @Path("/Groups/{groupId}") public Response patchGroup(@PathParam("tenantId") String tenantId, @@ -719,6 +744,239 @@ private void validatePatchSchema(JsonNode request) { } } + private void validatePatchUser(String identityStoreId, String userId, JsonNode request) { + ObjectNode describe = mapper.createObjectNode(); + describe.put("IdentityStoreId", identityStoreId); + describe.put("UserId", userId); + identityStoreService.describeUser(describe); + + JsonNode operations = request.get("Operations"); + if (operations == null || !operations.isArray() || operations.isEmpty()) { + throw validation("Operations must contain at least one patch operation."); + } + int userNameChanges = 0; + int activeChanges = 0; + for (JsonNode operation : operations) { + if (operation == null || !operation.isObject()) { + throw validation("Each patch operation must be an object."); + } + String op = requiredText(operation, "op"); + if (!List.of("add", "replace", "remove").contains(op)) { + throw validation("Unsupported patch operation: " + op); + } + String path = optionalText(operation, "path"); + JsonNode value = operation.get("value"); + if ("remove".equals(op) && (path == null || path.isBlank())) { + throw validation("path is required for remove operations."); + } + if (!"remove".equals(op) && (value == null || value.isNull())) { + throw validation("value is required for add and replace operations."); + } + if (path == null) { + if (!value.isObject() || value.isEmpty()) { + throw validation("A patch operation without path must provide an object value."); + } + var fields = value.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + validatePatchUserAttribute(identityStoreId, userId, op, entry.getKey(), entry.getValue()); + if ("userName".equals(entry.getKey())) { + userNameChanges++; + } else if ("active".equals(entry.getKey())) { + activeChanges++; + } + } + } else { + validatePatchUserAttribute(identityStoreId, userId, op, path, value); + if ("userName".equals(path)) { + userNameChanges++; + } else if ("active".equals(path)) { + activeChanges++; + } + } + } + if (userNameChanges > 1 || activeChanges > 1) { + throw validation("Multiple patch operations on userName or active are not supported."); + } + } + + private void validatePatchUserAttribute(String identityStoreId, String userId, String op, String path, JsonNode value) { + if (!PATCH_USER_ATTRIBUTES.contains(path)) { + throw validation("The user attribute cannot be patched: " + path); + } + if ("remove".equals(op) && ("userName".equals(path) || "active".equals(path))) { + throw validation(path + " cannot be removed."); + } + if ("active".equals(path)) { + patchBoolean(value, "active"); + return; + } + if ("userName".equals(path)) { + String userName = requirePatchText(value, "userName"); + boolean duplicate = identityStoreService.listUsersForScim(identityStoreId).stream() + .anyMatch(user -> userName.equals(user.userName()) && !userId.equals(user.userId())); + if (duplicate) { + throw new AwsException("ConflictException", "A user with UserName " + userName + " already exists.", 400); + } + return; + } + if ("externalId".equals(path)) { + if (!"remove".equals(op)) { + requirePatchText(value, "externalId"); + } + return; + } + if (List.of("displayName", "nickName", "profileUrl", "title", "userType", "preferredLanguage", "locale", "timezone") + .contains(path)) { + if (!"remove".equals(op)) { + requirePatchText(value, path); + } + return; + } + if ("name".equals(path)) { + if (!"remove".equals(op)) { + validatePatchName(value); + } + return; + } + if ("enterprise".equals(path)) { + if (!"remove".equals(op)) { + validatePatchEnterprise(value); + } + return; + } + if (List.of("emails", "addresses", "phoneNumbers").contains(path) && !"remove".equals(op)) { + ObjectNode wrapper = mapper.createObjectNode(); + wrapper.set(path, value.deepCopy()); + validateSingleValueArray(wrapper, path, "emails".equals(path), true); + } + } + + private void validatePatchName(JsonNode value) { + if (value == null || !value.isObject()) { + throw validation("name must be an object."); + } + for (String field : List.of("formatted", "familyName", "givenName", "middleName", "honorificPrefix", "honorificSuffix")) { + JsonNode fieldValue = value.get(field); + if (fieldValue != null && !fieldValue.isNull() && !fieldValue.isTextual()) { + throw validation("name." + field + " must be a string."); + } + } + } + + private void validatePatchEnterprise(JsonNode value) { + if (value == null || !value.isObject()) { + throw validation("enterprise must be an object."); + } + JsonNode manager = value.get("manager"); + if (manager != null && !manager.isNull()) { + if (!manager.isObject()) { + throw validation("enterprise.manager must be an object."); + } + if (manager.has("displayName")) { + throw validation("manager.displayName is not supported."); + } + } + } + + private void applyPatchUser(String identityStoreId, String userId, JsonNode request) { + for (JsonNode operation : request.get("Operations")) { + String op = operation.path("op").textValue(); + String path = optionalText(operation, "path"); + JsonNode value = operation.get("value"); + if (path == null) { + var fields = value.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + applyPatchUserAttribute(identityStoreId, userId, op, entry.getKey(), entry.getValue()); + } + } else { + applyPatchUserAttribute(identityStoreId, userId, op, path, value); + } + } + } + + private void applyPatchUserAttribute(String identityStoreId, String userId, String op, String path, JsonNode value) { + ObjectNode update = mapper.createObjectNode(); + update.put("IdentityStoreId", identityStoreId); + update.put("UserId", userId); + ObjectNode attributeOperation = update.putArray("Operations").addObject(); + switch (path) { + case "active" -> { + attributeOperation.put("AttributePath", "userStatus"); + attributeOperation.put("AttributeValue", patchBoolean(value, "active") ? "ENABLED" : "DISABLED"); + } + case "externalId" -> { + attributeOperation.put("AttributePath", "externalIds"); + if ("remove".equals(op)) { + attributeOperation.putNull("AttributeValue"); + } else { + ArrayNode externalIds = mapper.createArrayNode(); + externalIds.addObject().put("Issuer", USER_SCHEMA).put("Id", value.textValue()); + attributeOperation.set("AttributeValue", externalIds); + } + } + case "name" -> { + attributeOperation.put("AttributePath", "name"); + attributeOperation.set("AttributeValue", "remove".equals(op) ? mapper.nullNode() : scimNameToIdentityStore(value)); + } + case "enterprise" -> { + attributeOperation.put("AttributePath", IDENTITYSTORE_ENTERPRISE_EXTENSION); + attributeOperation.set("AttributeValue", "remove".equals(op) ? mapper.nullNode() : value.deepCopy()); + } + case "emails", "addresses", "phoneNumbers" -> { + attributeOperation.put("AttributePath", path); + if ("remove".equals(op)) { + attributeOperation.putNull("AttributeValue"); + } else { + ObjectNode source = mapper.createObjectNode(); + source.set(path, value.deepCopy()); + ObjectNode converted = mapper.createObjectNode(); + if ("emails".equals(path)) { + copyObjectArray(source, converted, path, "Emails", + Map.of("value", "Value", "type", "Type", "primary", "Primary")); + } else if ("addresses".equals(path)) { + copyObjectArray(source, converted, path, "Addresses", + Map.ofEntries(Map.entry("formatted", "Formatted"), Map.entry("streetAddress", "StreetAddress"), + Map.entry("locality", "Locality"), Map.entry("region", "Region"), + Map.entry("postalCode", "PostalCode"), Map.entry("country", "Country"), + Map.entry("type", "Type"), Map.entry("primary", "Primary"))); + } else { + copyObjectArray(source, converted, path, "PhoneNumbers", + Map.of("value", "Value", "type", "Type", "primary", "Primary")); + } + attributeOperation.set("AttributeValue", converted.elements().next()); + } + } + default -> { + attributeOperation.put("AttributePath", path); + attributeOperation.set("AttributeValue", "remove".equals(op) ? mapper.nullNode() : value.deepCopy()); + } + } + identityStoreService.updateUser(update); + } + + private ObjectNode scimNameToIdentityStore(JsonNode value) { + ObjectNode out = mapper.createObjectNode(); + copyText(value, out, "formatted", "Formatted"); + copyText(value, out, "familyName", "FamilyName"); + copyText(value, out, "givenName", "GivenName"); + copyText(value, out, "middleName", "MiddleName"); + copyText(value, out, "honorificPrefix", "HonorificPrefix"); + copyText(value, out, "honorificSuffix", "HonorificSuffix"); + return out; + } + + private static boolean patchBoolean(JsonNode value, String field) { + if (value != null && value.isBoolean()) { + return value.booleanValue(); + } + if (value != null && value.isTextual() && ("true".equals(value.textValue()) || "false".equals(value.textValue()))) { + return Boolean.parseBoolean(value.textValue()); + } + throw validation(field + " patch value must be true or false."); + } + private void validatePatchGroup(String identityStoreId, String groupId, JsonNode request) { ObjectNode describe = mapper.createObjectNode(); describe.put("IdentityStoreId", identityStoreId); diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index d1f3a0b438..b826bbac45 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -319,6 +319,89 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void patchUserUpdatesSupportedAttributesAndReturnsUser() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"patch-user-old\",\"userName\":\"patch-user@example.com\"," + + "\"displayName\":\"Patch User Old\"," + + "\"name\":{\"givenName\":\"Patch\",\"familyName\":\"User\"}," + + "\"emails\":[{\"value\":\"old@example.com\",\"primary\":true}]}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[" + + "{\"op\":\"replace\",\"path\":\"active\",\"value\":\"false\"}," + + "{\"op\":\"replace\",\"value\":{\"displayName\":\"Patch User New\"," + + "\"externalId\":\"patch-user-new\"}}," + + "{\"op\":\"replace\",\"path\":\"emails\",\"value\":[" + + "{\"value\":\"new@example.com\",\"type\":\"work\",\"primary\":true}]}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(200) + .body("id", equalTo(userId)) + .body("active", equalTo(false)) + .body("displayName", equalTo("Patch User New")) + .body("externalId", equalTo("patch-user-new")) + .body("emails[0].value", equalTo("new@example.com")); + } + + @Test + void patchUserRejectsUnsupportedAndRepeatedProtectedChanges() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"patch-user-validation@example.com\",\"displayName\":\"Patch Validation\"," + + "\"name\":{\"givenName\":\"Patch\",\"familyName\":\"Validation\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[" + + "{\"op\":\"replace\",\"path\":\"active\",\"value\":true}," + + "{\"op\":\"replace\",\"path\":\"active\",\"value\":false}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[" + + "{\"op\":\"remove\",\"path\":\"active\"}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:PatchOp\"],\"Operations\":[" + + "{\"op\":\"replace\",\"path\":\"password\",\"value\":\"nope\"}]}") + .when() + .patch("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + } + @Test void patchGroupUpdatesAttributesAndMemberships() { String firstUserId = given() From 25ac45014b167766bb6a16d8892415e4fce03951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 14:33:14 -0400 Subject: [PATCH 45/99] feat(scim): put users --- docs/services/identitystore.md | 2 + .../identitystore/IdentityStoreService.java | 22 ++++ .../identitystore/ScimController.java | 20 +++ .../identitystore/ScimIntegrationTest.java | 118 ++++++++++++++++++ 4 files changed, 162 insertions(+) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 2a24a51e04..4f4e980082 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -74,6 +74,8 @@ Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim `PatchUser` is supported through `PATCH /{tenant_id}/scim/v2/Users/{id}` with `add`, `replace`, and `remove` PatchOp operations. It supports the IAM Identity Center user attribute set, pathless object values for add/replace, textual or JSON boolean values for `active`, single-value email/address/phone constraints, enterprise/name updates, conflict detection for `userName`, and the AWS restriction against multiple `userName` or `active` changes in one request. Successful patches return HTTP `200` with the updated SCIM user representation. +`PutUser` is supported through `PUT /{tenant_id}/scim/v2/Users/{id}`. It applies IAM Identity Center's replacement semantics: create-time user validation is reused, the existing resource ID and creation timestamp are preserved, omitted mutable attributes are removed, `userName` conflicts return SCIM `409`, the shared Identity Store record is replaced atomically after validation, and the response is HTTP `201` with the replacement SCIM user representation. + SCIM validation failures use the standard `urn:ietf:params:scim:api:messages:2.0:Error` response shape. See the [IAM Identity Center SCIM implementation](https://docs.aws.amazon.com/singlesignon/latest/developerguide/what-is-scim.html) and [CreateGroup](https://docs.aws.amazon.com/singlesignon/latest/developerguide/creategroup.html) documentation. ## AWS-compatible failures diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index d3cfa37212..f44f39b0a1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java @@ -181,6 +181,28 @@ List listUsersForScim(String storeId) { return listUsersAll(requireStore(storeId), null); } + synchronized User replaceUserForScim(String storeId, String userId, JsonNode request) { + storeId = requireStore(storeId); + userId = requireResourceId(userId, "UserId"); + String finalUserId = userId; + User user = requireUser(storeId, finalUserId); + ObjectNode attributes = copyAttributes(request, Set.of("IdentityStoreId")); + String userName = optionalTextLength(attributes, "UserName", 1, 128); + if (userName != null) { + requireNotReserved(userName); + boolean duplicate = listUsersAll(storeId, userName).stream() + .anyMatch(candidate -> !candidate.userId().equals(finalUserId)); + if (duplicate) { + throw conflict("A user with UserName " + userName + " already exists."); + } + } + optionalTextLength(attributes, "DisplayName", 1, 1024); + user.setAttributes(attributes); + user.setUpdatedAt(Instant.now().toString()); + users.putForAccount(GLOBAL_PARTITION, userKey(storeId, userId), user); + return user; + } + public User describeUser(JsonNode request) { String storeId = requireStore(required(request, "IdentityStoreId")); return requireUser(storeId, requireResourceId(required(request, "UserId"), "UserId")); diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 8b27c6f61e..27bba74005 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -18,6 +18,7 @@ import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; @@ -369,6 +370,25 @@ public Response deleteGroup(@PathParam("tenantId") String tenantId, } } + @PUT + @Path("/Users/{userId}") + public Response putUser(@PathParam("tenantId") String tenantId, + @PathParam("userId") String userId, + @HeaderParam("Authorization") String authorization, + String body) { + try { + requireBearer(authorization); + String identityStoreId = resolveIdentityStore(tenantId); + JsonNode request = parseObject(body); + validateCreateUser(request); + User user = identityStoreService.replaceUserForScim( + identityStoreId, userId, toIdentityStoreUser(identityStoreId, request)); + return Response.status(Response.Status.CREATED).entity(userResponse(user)).build(); + } catch (AwsException exception) { + return scimError(scimStatus(exception), exception.getMessage()); + } + } + @POST @Path("/Users") public Response createUser(@PathParam("tenantId") String tenantId, diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index b826bbac45..36873c03ea 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -9,6 +9,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; @QuarkusTest class ScimIntegrationTest { @@ -319,6 +320,123 @@ void createGroupValidatesRequiredDisplayNameAndMemberLimit() { .body("status", equalTo("400")); } + @Test + void putUserOverwritesExistingUserAndPreservesResourceIdentity() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"externalId\":\"put-user-old\",\"userName\":\"put-user-old@example.com\"," + + "\"displayName\":\"Put User Old\",\"nickName\":\"OldNick\"," + + "\"name\":{\"givenName\":\"Put\",\"familyName\":\"Old\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + String createdAt = given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(200) + .extract().path("meta.created"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"id\":\"" + userId + "\",\"externalId\":\"put-user-new\"," + + "\"userName\":\"put-user-new@example.com\",\"displayName\":\"Put User New\"," + + "\"name\":{\"formatted\":\"Put User New\",\"givenName\":\"Put\",\"familyName\":\"New\"}," + + "\"emails\":[{\"value\":\"put-user-new@example.com\",\"type\":\"work\",\"primary\":true}]," + + "\"active\":false,\"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User\":{" + + "\"department\":\"Platform\",\"manager\":{\"value\":\"9067f2a3c1-00000000-0000-0000-0000-000000000077\"," + + "\"$ref\":\"../Users/9067f2a3c1-00000000-0000-0000-0000-000000000077\"}}}") + .when() + .put("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(201) + .body("id", equalTo(userId)) + .body("externalId", equalTo("put-user-new")) + .body("userName", equalTo("put-user-new@example.com")) + .body("displayName", equalTo("Put User New")) + .body("nickName", nullValue()) + .body("active", equalTo(false)) + .body("emails[0].value", equalTo("put-user-new@example.com")) + .body("meta.created", equalTo(createdAt)) + .body("'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'.department", equalTo("Platform")) + .body("'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'.manager.value", + equalTo("9067f2a3c1-00000000-0000-0000-0000-000000000077")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AWS_AUTH) + .header("X-Amz-Target", "AWSIdentityStore.DescribeUser") + .body("{\"IdentityStoreId\":\"" + STORE + "\",\"UserId\":\"" + userId + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("UserName", equalTo("put-user-new@example.com")) + .body("DisplayName", equalTo("Put User New")) + .body("NickName", nullValue()) + .body("UserStatus", equalTo("DISABLED")); + } + + @Test + void putUserRejectsInvalidReplacementAndConflictingUserName() { + String userId = given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"put-target@example.com\",\"displayName\":\"Put Target\"," + + "\"name\":{\"givenName\":\"Put\",\"familyName\":\"Target\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201) + .extract().path("id"); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"put-conflict@example.com\",\"displayName\":\"Put Conflict\"," + + "\"name\":{\"givenName\":\"Put\",\"familyName\":\"Conflict\"}}") + .when() + .post("/" + TENANT + "/scim/v2/Users") + .then() + .statusCode(201); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"put-target@example.com\",\"displayName\":\"Missing Name\"}") + .when() + .put("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(400) + .body("status", equalTo("400")); + + given() + .contentType("application/json") + .header("Authorization", BEARER) + .body("{\"userName\":\"put-conflict@example.com\",\"displayName\":\"Put Target Conflict\"," + + "\"name\":{\"givenName\":\"Put\",\"familyName\":\"Target\"}}") + .when() + .put("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(409) + .body("status", equalTo("409")); + + given() + .header("Authorization", BEARER) + .when() + .get("/" + TENANT + "/scim/v2/Users/" + userId) + .then() + .statusCode(200) + .body("userName", equalTo("put-target@example.com")) + .body("displayName", equalTo("Put Target")); + } + @Test void patchUserUpdatesSupportedAttributesAndReturnsUser() { String userId = given() From c278bdbb23530711b5d7a86216df5cdde3077af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 14:44:32 -0400 Subject: [PATCH 46/99] feat(ssoadmin): delete ABAC configuration --- .../test/SsoAdminAccountAssignmentTest.java | 12 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 9 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 50 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 27 ++++++++++ 6 files changed, 105 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 4c3beb91cb..f6563480d2 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -290,6 +290,18 @@ void createInstanceAccessControlAttributeConfigurationUsesAwsSdk() { .key("Department") .value(value -> value.source("${path:enterprise.department}")))))) .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + + var deleteResponse = sso.deleteInstanceAccessControlAttributeConfiguration(request -> request + .instanceArn(instanceArn)); + assertThat(deleteResponse.sdkHttpResponse().isSuccessful()).isTrue(); + + var recreateResponse = sso.createInstanceAccessControlAttributeConfiguration(request -> request + .instanceArn(instanceArn) + .instanceAccessControlAttributeConfiguration(configuration -> configuration + .accessControlAttributes(attribute -> attribute + .key("Department") + .value(value -> value.source("${path:enterprise.department}"))))); + assertThat(recreateResponse.sdkHttpResponse().isSuccessful()).isTrue(); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 61b6e041de..8c8e693a2f 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -14,6 +14,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | | `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | +| `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 21701b0c59..83662369f6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -31,6 +31,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateInstance" -> createInstance(request, callerAccountId, region); case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); + case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); @@ -118,6 +119,11 @@ private Response createInstanceAccessControlAttributeConfiguration(JsonNode requ return Response.ok().build(); } + private Response deleteInstanceAccessControlAttributeConfiguration(JsonNode request) { + service.deleteInstanceAccessControlAttributeConfiguration(request); + return Response.ok().build(); + } + private Response createTrustedTokenIssuer(JsonNode request, String callerAccountId) { var issuer = service.createTrustedTokenIssuer(request, callerAccountId); return Response.ok(mapper.createObjectNode() diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ac3e75c734..6da7d012dc 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -382,6 +382,15 @@ public InstanceAccessControlAttributeConfiguration getInstanceAccessControlAttri .orElseThrow(() -> notFound("Instance access control attribute configuration not found for: " + instanceArn)); } + public synchronized void deleteInstanceAccessControlAttributeConfiguration(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + requireInstance(instanceArn); + if (accessControlAttributeConfigurations.get(instanceArn).isEmpty()) { + throw notFound("Instance access control attribute configuration not found for: " + instanceArn); + } + accessControlAttributeConfigurations.delete(instanceArn); + } + public synchronized SsoInstance createInstance(JsonNode request, String callerAccountId, String region) { validateAccountId(callerAccountId); validateRegionName(region); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index c2640b143c..34117701f8 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -305,6 +305,56 @@ void createInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); } + @Test + void deleteInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() { + String createRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"InstanceAccessControlAttributeConfiguration\":{\"AccessControlAttributes\":[{" + + "\"Key\":\"CostCenter\",\"Value\":{\"Source\":[\"${path:enterprise.costCenter}\"]}}]}}"; + + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteInstanceAccessControlAttributeConfiguration") + .body(request) + .when() + .post("/") + .then() + .statusCode(org.hamcrest.Matchers.anyOf(org.hamcrest.Matchers.is(200), org.hamcrest.Matchers.is(400))); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateInstanceAccessControlAttributeConfiguration") + .body(createRequest) + .when() + .post("/") + .then() + .statusCode(200); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteInstanceAccessControlAttributeConfiguration") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteInstanceAccessControlAttributeConfiguration") + .body(request) + .when() + .post("/") + .then() + .statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + } + @Test void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { String auth = "AWS4-HMAC-SHA256 Credential=222233334444/20260101/us-west-2/sso/aws4_request"; diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 182914eaec..97503f9a5f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -167,6 +167,33 @@ void createInstanceAccessControlAttributeConfigurationRejectsInvalidAttributes() () -> service.createInstanceAccessControlAttributeConfiguration(invalidSourceCount)); } + @Test + void deleteInstanceAccessControlAttributeConfigurationRemovesAbacConfiguration() { + ObjectNode create = mapper.createObjectNode(); + create.put("InstanceArn", service.getInstanceArn()); + create.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes") + .addObject() + .put("Key", "Department") + .putObject("Value") + .putArray("Source") + .add("${path:enterprise.department}"); + service.createInstanceAccessControlAttributeConfiguration(create); + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + service.deleteInstanceAccessControlAttributeConfiguration(request); + + assertError("ResourceNotFoundException", + () -> service.getInstanceAccessControlAttributeConfiguration(service.getInstanceArn())); + assertError("ResourceNotFoundException", + () -> service.deleteInstanceAccessControlAttributeConfiguration(request)); + + InstanceAccessControlAttributeConfiguration recreated = + service.createInstanceAccessControlAttributeConfiguration(create); + assertEquals("ENABLED", recreated.status()); + } + @Test void deleteInstanceRequiresOwnerAndAllowsRecreation() { SsoAdminService emptyService = emptyService(); From bb9254758a9980e807aea0d06fb61922b2df1bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 14:49:32 -0400 Subject: [PATCH 47/99] feat(ssoadmin): delete trusted token issuers --- .../test/SsoAdminAccountAssignmentTest.java | 7 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++ .../services/ssoadmin/SsoAdminService.java | 20 ++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 37 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 19 ++++++++++ 6 files changed, 90 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index f6563480d2..c3f2942496 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -265,6 +265,13 @@ void createTrustedTokenIssuerUsesAwsSdk() { .issuerUrl("https://issuer.example.com") .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); assertThat(replay.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); + + var deleteResponse = sso.deleteTrustedTokenIssuer(request -> request + .trustedTokenIssuerArn(created.trustedTokenIssuerArn())); + assertThat(deleteResponse.sdkHttpResponse().isSuccessful()).isTrue(); + assertThatThrownBy(() -> sso.deleteTrustedTokenIssuer(request -> request + .trustedTokenIssuerArn(created.trustedTokenIssuerArn()))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 8c8e693a2f..7dfdd5f3fb 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -16,6 +16,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | +| `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 83662369f6..8a0f3f1782 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -33,6 +33,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); + case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -130,6 +131,11 @@ private Response createTrustedTokenIssuer(JsonNode request, String callerAccount .put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn())).build(); } + private Response deleteTrustedTokenIssuer(JsonNode request) { + service.deleteTrustedTokenIssuer(request); + return Response.ok().build(); + } + private Response addRegion(JsonNode request) { var region = service.addRegion(request); return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6da7d012dc..f2d0ca6ef4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -60,6 +60,7 @@ public class SsoAdminService implements Resettable { private static final Pattern REGION_NAME = Pattern.compile("([a-z]+-){2,3}\\d"); private static final Pattern APPLICATION_PROVIDER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::aws:applicationProvider/[a-zA-Z0-9-/]+"); private static final Pattern APPLICATION_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); + private static final Pattern TRUSTED_TOKEN_ISSUER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:trustedTokenIssuer/(?:sso)?ins-[a-zA-Z0-9-.]{16}/tti-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"); private static final Pattern APPLICATION_ACCESS_SCOPE = Pattern.compile("([A-Za-z0-9_]{1,50})(:[A-Za-z0-9_]{1,50}){0,1}(:[A-Za-z0-9_]{1,50}){0,1}"); private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); @@ -317,10 +318,29 @@ public synchronized TrustedTokenIssuer createTrustedTokenIssuer(JsonNode request } public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { + validateTrustedTokenIssuerArn(trustedTokenIssuerArn); return trustedTokenIssuers.get(trustedTokenIssuerArn) .orElseThrow(() -> notFound("Trusted token issuer not found: " + trustedTokenIssuerArn)); } + public synchronized void deleteTrustedTokenIssuer(JsonNode request) { + String trustedTokenIssuerArn = required(request, "TrustedTokenIssuerArn"); + getTrustedTokenIssuer(trustedTokenIssuerArn); + trustedTokenIssuers.delete(trustedTokenIssuerArn); + for (String key : new ArrayList<>(trustedTokenIssuerClientTokens.keys())) { + if (trustedTokenIssuerArn.equals(trustedTokenIssuerClientTokens.get(key).orElse(null))) { + trustedTokenIssuerClientTokens.delete(key); + } + } + } + + private static void validateTrustedTokenIssuerArn(String trustedTokenIssuerArn) { + if (trustedTokenIssuerArn == null || trustedTokenIssuerArn.length() < 10 || trustedTokenIssuerArn.length() > 1224 + || !TRUSTED_TOKEN_ISSUER_ARN.matcher(trustedTokenIssuerArn).matches()) { + throw validation("TrustedTokenIssuerArn is invalid."); + } + } + private static boolean trustedTokenIssuerMatches(TrustedTokenIssuer issuer, String name, String issuerType, OidcJwtIssuerConfiguration oidcConfiguration, Map tags) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 34117701f8..46ddfd5564 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -287,6 +287,43 @@ void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { .body("TrustedTokenIssuerArn", equalTo(arn)); } + @Test + void deleteTrustedTokenIssuerReturnsEmptyAwsResponse() { + String createRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"Name\":\"DeleteIntegrationIssuer\",\"ClientToken\":\"delete-tti-integration-token\"," + + "\"TrustedTokenIssuerType\":\"OIDC_JWT\",\"TrustedTokenIssuerConfiguration\":{" + + "\"OidcJwtConfiguration\":{\"ClaimAttributePath\":\"sub\"," + + "\"IdentityStoreAttributePath\":\"userName\",\"IssuerUrl\":\"https://delete-issuer.example.com\"," + + "\"JwksRetrievalOption\":\"OPEN_ID_DISCOVERY\"}}}"; + String arn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateTrustedTokenIssuer") + .body(createRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("TrustedTokenIssuerArn"); + + String deleteRequest = "{\"TrustedTokenIssuerArn\":\"" + arn + "\"}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteTrustedTokenIssuer") + .body(deleteRequest) + .when().post("/") + .then().statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DeleteTrustedTokenIssuer") + .body(deleteRequest) + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + } + @Test void createInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 97503f9a5f..01838ce86d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -94,6 +94,25 @@ void createTrustedTokenIssuerPersistsOidcConfigurationAndSupportsIdempotency() { () -> service.createTrustedTokenIssuer(mismatch, ACCOUNT_ID)); } + @Test + void deleteTrustedTokenIssuerRemovesIssuerAndIdempotencyToken() { + ObjectNode create = trustedTokenIssuerRequest("DeleteIssuer", "delete-tti-token"); + TrustedTokenIssuer issuer = service.createTrustedTokenIssuer(create, ACCOUNT_ID); + + ObjectNode request = mapper.createObjectNode(); + request.put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn()); + service.deleteTrustedTokenIssuer(request); + assertError("ResourceNotFoundException", () -> service.getTrustedTokenIssuer(issuer.trustedTokenIssuerArn())); + assertError("ResourceNotFoundException", () -> service.deleteTrustedTokenIssuer(request)); + + TrustedTokenIssuer recreated = service.createTrustedTokenIssuer(create, ACCOUNT_ID); + assertFalse(recreated.trustedTokenIssuerArn().equals(issuer.trustedTokenIssuerArn())); + + ObjectNode malformed = mapper.createObjectNode(); + malformed.put("TrustedTokenIssuerArn", "not-an-arn"); + assertError("ValidationException", () -> service.deleteTrustedTokenIssuer(malformed)); + } + @Test void createTrustedTokenIssuerValidatesUnionOidcFieldsAndQuota() { ObjectNode invalidType = trustedTokenIssuerRequest("InvalidType", null); From a80b9347f88339c40c46b5060d062c3d453219ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 15:12:56 -0400 Subject: [PATCH 48/99] feat(ssoadmin): describe applications --- .../test/SsoAdminAccountAssignmentTest.java | 10 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 32 +++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 4 ++ .../ssoadmin/SsoAdminIntegrationTest.java | 39 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 16 ++++++++ 6 files changed, 102 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c3f2942496..42d23a62da 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -124,6 +124,16 @@ void createApplicationUsesAwsSdk() { .applicationUrl("https://example.com/login"))) .tags(tag -> tag.key("Environment").value("test"))); assertThat(replay.applicationArn()).isEqualTo(created.applicationArn()); + + var described = sso.describeApplication(request -> request.applicationArn(created.applicationArn())); + assertThat(described.applicationArn()).isEqualTo(created.applicationArn()); + assertThat(described.instanceArn()).isEqualTo(instanceArn); + assertThat(described.applicationAccount()).isEqualTo("000000000000"); + assertThat(described.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); + assertThat(described.name()).isEqualTo("Floci OAuth SDK"); + assertThat(described.statusAsString()).isEqualTo("DISABLED"); + assertThat(described.portalOptions().visibilityAsString()).isEqualTo("ENABLED"); + assertThat(described.portalOptions().signInOptions().originAsString()).isEqualTo("APPLICATION"); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 7dfdd5f3fb..d00cc5cfaf 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -19,6 +19,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | +| `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 8a0f3f1782..69c80a5baa 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -36,6 +36,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); + case "DescribeApplication" -> describeApplication(request); case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); @@ -180,6 +181,37 @@ private Response createApplication(JsonNode request, String callerAccountId, Str return Response.ok(response).build(); } + private Response describeApplication(JsonNode request) { + SsoApplication application = service.describeApplication(request); + ObjectNode response = mapper.createObjectNode(); + response.put("ApplicationAccount", application.applicationAccount()); + response.put("ApplicationArn", application.applicationArn()); + response.put("ApplicationProviderArn", application.applicationProviderArn()); + response.put("CreatedDate", application.createdDateEpochMillis() / 1000.0d); + response.put("CreatedFrom", application.createdFrom()); + if (application.description() != null) { + response.put("Description", application.description()); + } + response.put("IdentityStoreArn", application.identityStoreArn()); + response.put("InstanceArn", application.instanceArn()); + response.put("Name", application.name()); + if (application.portalOptions() != null) { + ObjectNode portal = response.putObject("PortalOptions"); + if (application.portalOptions().visibility() != null) { + portal.put("Visibility", application.portalOptions().visibility()); + } + if (application.portalOptions().signInOptions() != null) { + ObjectNode signIn = portal.putObject("SignInOptions"); + signIn.put("Origin", application.portalOptions().signInOptions().origin()); + if (application.portalOptions().signInOptions().applicationUrl() != null) { + signIn.put("ApplicationUrl", application.portalOptions().signInOptions().applicationUrl()); + } + } + } + response.put("Status", application.status()); + return Response.ok(response).build(); + } + private Response listPermissionSets(JsonNode request) { var page = service.listPermissionSets(request); ObjectNode response = mapper.createObjectNode(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f2d0ca6ef4..1cc5fa53fa 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1330,6 +1330,10 @@ private static void validateInlinePolicy(String policy) { } } + public SsoApplication describeApplication(JsonNode request) { + return getApplication(validateApplicationArn(required(request, "ApplicationArn"))); + } + SsoApplication getApplication(String applicationArn) { validateApplicationArn(applicationArn); return applications.get(applicationArn).orElseThrow(() -> notFound("Application not found: " + applicationArn)); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 46ddfd5564..a0276cdbc9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -132,6 +132,45 @@ void createApplicationReturnsAwsIdentifiersAndIsIdempotent() { .body("ApplicationArn", equalTo(applicationArn)); } + @Test + void describeApplicationReturnsAwsApplicationShape() { + String createRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Describe Integration\",\"Description\":\"Describe application\"," + + "\"Status\":\"DISABLED\",\"ClientToken\":\"describe-integration-token\"," + + "\"PortalOptions\":{\"Visibility\":\"ENABLED\",\"SignInOptions\":{" + + "\"Origin\":\"APPLICATION\",\"ApplicationUrl\":\"https://example.com/login\"}}}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(createRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeApplication") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("ApplicationAccount", equalTo("000000000000")) + .body("ApplicationArn", equalTo(applicationArn)) + .body("ApplicationProviderArn", equalTo("arn:aws:sso::aws:applicationProvider/custom")) + .body("CreatedDate", org.hamcrest.Matchers.notNullValue()) + .body("CreatedFrom", equalTo("us-east-1")) + .body("Description", equalTo("Describe application")) + .body("IdentityStoreArn", equalTo("arn:aws:identitystore::000000000000:identitystore/d-9067f2a3c1")) + .body("InstanceArn", equalTo("arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e")) + .body("Name", equalTo("Describe Integration")) + .body("PortalOptions.Visibility", equalTo("ENABLED")) + .body("PortalOptions.SignInOptions.Origin", equalTo("APPLICATION")) + .body("PortalOptions.SignInOptions.ApplicationUrl", equalTo("https://example.com/login")) + .body("Status", equalTo("DISABLED")); + } + @Test void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 01838ce86d..dba45b13f7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -322,6 +322,22 @@ void createInstanceRejectsOrganizationsManagementAccounts() { () -> emptyService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "us-east-1")); } + @Test + void describeApplicationReturnsPersistedApplicationAndValidatesArn() { + SsoApplication application = createApplication("Describe App", "describe-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + assertEquals(application, service.describeApplication(request)); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationArn", "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.describeApplication(missing)); + + ObjectNode malformed = mapper.createObjectNode(); + malformed.put("ApplicationArn", "not-an-arn"); + assertError("ValidationException", () -> service.describeApplication(malformed)); + } + @Test void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { ObjectNode createRequest = mapper.createObjectNode(); From 04f5b37a49bfe85bba440e1c7f3de1aa98ba2120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 15:17:50 -0400 Subject: [PATCH 49/99] feat(ssoadmin): describe application assignments --- .../test/SsoAdminAccountAssignmentTest.java | 9 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 11 +++++++++ .../services/ssoadmin/SsoAdminService.java | 13 ++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 13 ++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 24 +++++++++++++++++++ 6 files changed, 71 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 42d23a62da..b5966f2787 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -155,6 +155,15 @@ void createApplicationAssignmentUsesAwsSdk() { .principalId("11111111-2222-3333-4444-555555555555") .principalType("USER")); assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + + var described = sso.describeApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId("11111111-2222-3333-4444-555555555555") + .principalType("USER")); + assertThat(described.applicationArn()).isEqualTo(applicationArn); + assertThat(described.principalId()).isEqualTo("11111111-2222-3333-4444-555555555555"); + assertThat(described.principalTypeAsString()).isEqualTo("USER"); + assertThatThrownBy(() -> sso.createApplicationAssignment(request -> request .applicationArn(applicationArn) .principalId("11111111-2222-3333-4444-555555555555") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d00cc5cfaf..9020346af0 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -21,6 +21,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | +| `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 69c80a5baa..32440ad51e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; import io.github.hectorvent.floci.services.ssoadmin.model.SsoApplication; @@ -38,6 +39,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "CreateApplicationAssignment" -> createApplicationAssignment(request); + case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); @@ -157,6 +159,15 @@ private Response createApplicationAssignment(JsonNode request) { return Response.ok().build(); } + private Response describeApplicationAssignment(JsonNode request) { + ApplicationAssignment assignment = service.describeApplicationAssignment(request); + ObjectNode response = mapper.createObjectNode(); + response.put("ApplicationArn", assignment.applicationArn()); + response.put("PrincipalId", assignment.principalId()); + response.put("PrincipalType", assignment.principalType()); + return Response.ok(response).build(); + } + private Response deleteApplicationAssignment(JsonNode request) { service.deleteApplicationAssignment(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 1cc5fa53fa..95f2206ec7 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -553,6 +553,19 @@ public synchronized void deleteApplicationAccessScope(JsonNode request) { applicationAccessScopes.delete(key); } + public ApplicationAssignment describeApplicationAssignment(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String principalId = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + String key = applicationAssignmentKey(applicationArn, principalId, principalType); + return applicationAssignments.get(key) + .orElseThrow(() -> notFound("Application assignment not found for the specified principal.")); + } + public synchronized void deleteApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index a0276cdbc9..ffb06bf348 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -197,6 +197,19 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .then() .statusCode(200) .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeApplicationAssignment") + .body(assignmentRequest) + .when() + .post("/") + .then() + .statusCode(200) + .body("ApplicationArn", equalTo(applicationArn)) + .body("PrincipalId", equalTo("11111111-2222-3333-4444-555555555555")) + .body("PrincipalType", equalTo("USER")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index dba45b13f7..0a5f0696d0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -448,6 +448,30 @@ void createApplicationAssignmentValidatesApplicationPrincipalAndDuplicates() { assertError("ResourceNotFoundException", () -> service.createApplicationAssignment(missingApplication)); } + @Test + void describeApplicationAssignmentReturnsDirectAssignmentAndValidatesRequest() { + SsoApplication application = createApplication("Describe Assignment App", "describe-assignment-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("PrincipalId", PRINCIPAL_ID); + request.put("PrincipalType", "GROUP"); + ApplicationAssignment created = service.createApplicationAssignment(request); + + assertEquals(created, service.describeApplicationAssignment(request)); + + ObjectNode wrongType = request.deepCopy(); + wrongType.put("PrincipalType", "USER"); + assertError("ResourceNotFoundException", () -> service.describeApplicationAssignment(wrongType)); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("PrincipalType", "ROLE"); + assertError("ValidationException", () -> service.describeApplicationAssignment(invalidType)); + + ObjectNode invalidPrincipal = request.deepCopy(); + invalidPrincipal.put("PrincipalId", "not-a-guid"); + assertError("ValidationException", () -> service.describeApplicationAssignment(invalidPrincipal)); + } + @Test void deleteApplicationAssignmentRevokesDirectAssignmentAndValidatesPrincipal() { SsoApplication application = createApplication("Delete Assignment App", "delete-assignment-app-token"); From c9719a47b6fc219041bf62cb5890f7bf3b983d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 15:24:08 -0400 Subject: [PATCH 50/99] feat(ssoadmin): describe instances --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 +++++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 4 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 18 +++++++++++++++++- .../ssoadmin/SsoAdminServiceTest.java | 2 ++ 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index b5966f2787..6f379be99f 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -344,6 +344,13 @@ void createInstanceUsesAwsSdk() { .tags(tag -> tag.key("Environment").value("test"))) .instanceArn(); + var described = sso.describeInstance(request -> request.instanceArn(instanceArn)); + assertThat(described.instanceArn()).isEqualTo(instanceArn); + assertThat(described.ownerAccountId()).isEqualTo("333344445555"); + assertThat(described.name()).isEqualTo("SdkAccountInstance"); + assertThat(described.statusAsString()).isEqualTo("ACTIVE"); + assertThat(described.permissionSetsEnabled()).isFalse(); + var listed = sso.listInstances(request -> {}).instances(); assertThat(listed).hasSize(1); assertThat(listed.get(0).instanceArn()).isEqualTo(instanceArn); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 9020346af0..01cf4f39d8 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -12,6 +12,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | +| `DescribeInstance` | Returns IAM Identity Center instance metadata, status, identity store ID, owner account, creation date, and permission-set availability. | | `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 32440ad51e..8d5b943615 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -30,6 +30,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, return switch (action) { case "ListInstances" -> listInstances(callerAccountId); case "CreateInstance" -> createInstance(request, callerAccountId, region); + case "DescribeInstance" -> describeInstance(request); case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); @@ -113,6 +114,24 @@ private Response createInstance(JsonNode request, String callerAccountId, String return Response.ok(mapper.createObjectNode().put("InstanceArn", instance.instanceArn())).build(); } + private Response describeInstance(JsonNode request) { + var instance = service.describeInstance(request); + ObjectNode response = mapper.createObjectNode(); + response.put("CreatedDate", instance.createdDateEpochMillis() / 1000.0d); + response.put("IdentityStoreId", instance.identityStoreId()); + response.put("InstanceArn", instance.instanceArn()); + if (instance.name() != null) { + response.put("Name", instance.name()); + } + response.put("OwnerAccountId", instance.ownerAccountId()); + response.put("PermissionSetsEnabled", !instance.accountInstance()); + response.put("Status", instance.status()); + if (instance.statusReason() != null) { + response.put("StatusReason", instance.statusReason()); + } + return Response.ok(response).build(); + } + private Response deleteInstance(JsonNode request, String callerAccountId) { service.deleteInstance(request, callerAccountId); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 95f2206ec7..d22a24589c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -455,6 +455,10 @@ public synchronized SsoInstance createInstance(JsonNode request, String callerAc return instance; } + public SsoInstance describeInstance(JsonNode request) { + return requireInstance(required(request, "InstanceArn")); + } + public synchronized void deleteInstance(JsonNode request, String callerAccountId) { validateAccountId(callerAccountId); String instanceArn = required(request, "InstanceArn"); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index ffb06bf348..6e8eb60c30 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -473,6 +473,22 @@ void createInstanceCreatesOneAccountInstanceAndReplaysClientToken() { .statusCode(200) .body("InstanceArn", equalTo(instanceArn)); + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", auth) + .header("X-Amz-Target", "SWBExternalService.DescribeInstance") + .body("{\"InstanceArn\":\"" + instanceArn + "\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("InstanceArn", equalTo(instanceArn)) + .body("OwnerAccountId", equalTo("222233334444")) + .body("Name", equalTo("StandaloneInstance")) + .body("Status", equalTo("ACTIVE")) + .body("PermissionSetsEnabled", equalTo(false)) + .body("IdentityStoreId", matchesPattern("d-[0-9a-f]{10}")) + .body("CreatedDate", org.hamcrest.Matchers.notNullValue()); given() .contentType("application/x-amz-json-1.1") .header("Authorization", auth) @@ -729,7 +745,7 @@ void unknownAction_returnsUnknownOperationException() { given() .contentType("application/x-amz-json-1.1") .header("Authorization", AUTH_HEADER) - .header("X-Amz-Target", "SWBExternalService.DescribeInstance") + .header("X-Amz-Target", "SWBExternalService.NotARealOperation") .body("{}") .when() .post("/") diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 0a5f0696d0..87bdef08c5 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -275,6 +275,8 @@ void createInstancePersistsMetadataAndSupportsIdempotentReplay() { assertTrue(created.accountInstance()); assertTrue(created.instanceArn().matches("arn:aws:sso:::instance/ssoins-[0-9a-f]{16}")); assertTrue(created.identityStoreId().matches("d-[0-9a-f]{10}")); + ObjectNode describeRequest = mapper.createObjectNode().put("InstanceArn", created.instanceArn()); + assertEquals(created, emptyService.describeInstance(describeRequest)); assertEquals(created.instanceArn(), emptyService.createInstance(request, ACCOUNT_ID, "us-west-2").instanceArn()); assertEquals(1, emptyService.listInstances(ACCOUNT_ID).size()); From a5fe941074f1fc70892a2d8ed76a201e523dec37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 15:29:21 -0400 Subject: [PATCH 51/99] feat(ssoadmin): describe ABAC configuration --- .../test/SsoAdminAccountAssignmentTest.java | 9 +++++++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 18 ++++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 4 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 14 ++++++++++++++ .../services/ssoadmin/SsoAdminServiceTest.java | 2 ++ 6 files changed, 48 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 6f379be99f..a2aaf0c532 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -309,6 +309,15 @@ void createInstanceAccessControlAttributeConfigurationUsesAwsSdk() { .value(value -> value.source("${path:enterprise.department}"))))); assertThat(response.sdkHttpResponse().isSuccessful()).isTrue(); + + var described = sso.describeInstanceAccessControlAttributeConfiguration(request -> request.instanceArn(instanceArn)); + assertThat(described.statusAsString()).isEqualTo("ENABLED"); + assertThat(described.instanceAccessControlAttributeConfiguration().accessControlAttributes()) + .singleElement().satisfies(attribute -> { + assertThat(attribute.key()).isEqualTo("Department"); + assertThat(attribute.value().source()).containsExactly("${path:enterprise.department}"); + }); + assertThatThrownBy(() -> sso.createInstanceAccessControlAttributeConfiguration(request -> request .instanceArn(instanceArn) .instanceAccessControlAttributeConfiguration(configuration -> configuration diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 01cf4f39d8..d5178340b2 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -15,6 +15,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeInstance` | Returns IAM Identity Center instance metadata, status, identity store ID, owner account, creation date, and permission-set availability. | | `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | +| `DescribeInstanceAccessControlAttributeConfiguration` | Returns the IAM Identity Center ABAC attribute configuration and its creation status for an instance. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 8d5b943615..3dbc66ea54 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -33,6 +33,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeInstance" -> describeInstance(request); case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); + case "DescribeInstanceAccessControlAttributeConfiguration" -> describeInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); @@ -142,6 +143,23 @@ private Response createInstanceAccessControlAttributeConfiguration(JsonNode requ return Response.ok().build(); } + private Response describeInstanceAccessControlAttributeConfiguration(JsonNode request) { + var configuration = service.describeInstanceAccessControlAttributeConfiguration(request); + ObjectNode response = mapper.createObjectNode(); + ObjectNode configurationNode = response.putObject("InstanceAccessControlAttributeConfiguration"); + ArrayNode attributes = configurationNode.putArray("AccessControlAttributes"); + configuration.accessControlAttributes().forEach(attribute -> { + ObjectNode attributeNode = attributes.addObject(); + attributeNode.put("Key", attribute.key()); + attributeNode.putObject("Value").putArray("Source").add(attribute.source()); + }); + response.put("Status", configuration.status()); + if (configuration.statusReason() != null) { + response.put("StatusReason", configuration.statusReason()); + } + return Response.ok(response).build(); + } + private Response deleteInstanceAccessControlAttributeConfiguration(JsonNode request) { service.deleteInstanceAccessControlAttributeConfiguration(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index d22a24589c..709424f796 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -396,6 +396,10 @@ public synchronized InstanceAccessControlAttributeConfiguration createInstanceAc return configuration; } + public InstanceAccessControlAttributeConfiguration describeInstanceAccessControlAttributeConfiguration(JsonNode request) { + return getInstanceAccessControlAttributeConfiguration(required(request, "InstanceArn")); + } + public InstanceAccessControlAttributeConfiguration getInstanceAccessControlAttributeConfiguration(String instanceArn) { requireInstance(instanceArn); return accessControlAttributeConfigurations.get(instanceArn) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 6e8eb60c30..9c9d92c042 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -392,6 +392,20 @@ void createInstanceAccessControlAttributeConfigurationReturnsEmptyAwsResponse() .then() .statusCode(200) .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeInstanceAccessControlAttributeConfiguration") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Status", equalTo("ENABLED")) + .body("InstanceAccessControlAttributeConfiguration.AccessControlAttributes[0].Key", equalTo("Department")) + .body("InstanceAccessControlAttributeConfiguration.AccessControlAttributes[0].Value.Source[0]", + equalTo("${path:enterprise.department}")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 87bdef08c5..1659287ba9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -150,6 +150,8 @@ void createInstanceAccessControlAttributeConfigurationPersistsAndValidatesAwsSha assertEquals("Department", created.accessControlAttributes().get(0).key()); assertEquals("${path:enterprise.department}", created.accessControlAttributes().get(0).source()); assertEquals(created, service.getInstanceAccessControlAttributeConfiguration(service.getInstanceArn())); + ObjectNode describeRequest = mapper.createObjectNode().put("InstanceArn", service.getInstanceArn()); + assertEquals(created, service.describeInstanceAccessControlAttributeConfiguration(describeRequest)); assertError("ConflictException", () -> service.createInstanceAccessControlAttributeConfiguration(request)); } From 1e5e1e659c79b232a5ddb14a9cda6783cb74e430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 15:33:24 -0400 Subject: [PATCH 52/99] feat(ssoadmin): describe regions --- .../floci/test/SsoAdminAccountAssignmentTest.java | 9 +++++++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 11 +++++++++++ .../floci/services/ssoadmin/SsoAdminService.java | 9 +++++++++ .../services/ssoadmin/SsoAdminIntegrationTest.java | 14 ++++++++++++++ .../services/ssoadmin/SsoAdminServiceTest.java | 14 ++++++++++++++ 6 files changed, 58 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index a2aaf0c532..9457ad2109 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -26,6 +26,15 @@ void addRegionUsesAwsSdk() { .regionName("ap-southeast-3")); assertThat(added.statusAsString()).isEqualTo("ADDING"); + + var described = sso.describeRegion(request -> request + .instanceArn(instanceArn) + .regionName("ap-southeast-3")); + assertThat(described.regionName()).isEqualTo("ap-southeast-3"); + assertThat(described.statusAsString()).isEqualTo("ACTIVE"); + assertThat(described.isPrimaryRegion()).isFalse(); + assertThat(described.addedDate()).isNotNull(); + assertThatThrownBy(() -> sso.addRegion(request -> request .instanceArn(instanceArn) .regionName("ap-southeast-3"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d5178340b2..f007931178 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -20,6 +20,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | +| `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 3dbc66ea54..c5fd2f4526 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -38,6 +38,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); + case "DescribeRegion" -> describeRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -181,6 +182,16 @@ private Response addRegion(JsonNode request) { return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); } + private Response describeRegion(JsonNode request) { + var region = service.describeRegion(request); + ObjectNode response = mapper.createObjectNode(); + response.put("AddedDate", service.regionAddedDateEpochSeconds(region)); + response.put("IsPrimaryRegion", region.primaryRegion()); + response.put("RegionName", region.regionName()); + response.put("Status", region.status()); + return Response.ok(response).build(); + } + private Response deleteApplication(JsonNode request) { service.deleteApplication(SsoAdminService.required(request, "ApplicationArn")); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 709424f796..96c6c1b52f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -701,6 +701,15 @@ public synchronized SsoApplication createApplication(JsonNode request, String ca return application; } + public RegionMetadata describeRegion(JsonNode request) { + SsoInstance instance = requireInstance(required(request, "InstanceArn")); + String regionName = validateRegionName(required(request, "RegionName")); + return listRegionsForInstance(instance).stream() + .filter(region -> regionName.equals(region.regionName())) + .findFirst() + .orElseThrow(() -> notFound("Region is not enabled for this IAM Identity Center instance: " + regionName)); + } + public synchronized RegionMetadata addRegion(JsonNode request) { requireInstance(required(request, "InstanceArn")); String regionName = validateRegionName(required(request, "RegionName")); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 9c9d92c042..9782fd85bc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -64,6 +64,20 @@ void addRegionReturnsAddingAndRejectsDuplicate() { .statusCode(200) .body("Status", equalTo("ADDING")); + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeRegion") + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .body("RegionName", equalTo("eu-west-2")) + .body("Status", equalTo("ACTIVE")) + .body("IsPrimaryRegion", equalTo(false)) + .body("AddedDate", org.hamcrest.Matchers.notNullValue()); + given() .contentType("application/x-amz-json-1.1") .header("Authorization", AUTH_HEADER) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 1659287ba9..65109ff386 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -569,6 +569,20 @@ void addRegionValidatesAndRejectsDuplicates() { assertFalse(created.primaryRegion()); assertNotNull(created.addedDate()); + RegionMetadata described = service.describeRegion(request); + assertEquals("us-west-2", described.regionName()); + assertEquals("ACTIVE", described.status()); + assertFalse(described.primaryRegion()); + + ObjectNode primaryDescribe = mapper.createObjectNode(); + primaryDescribe.put("InstanceArn", service.getInstanceArn()); + primaryDescribe.put("RegionName", "us-east-1"); + assertTrue(service.describeRegion(primaryDescribe).primaryRegion()); + + ObjectNode missingDescribe = request.deepCopy(); + missingDescribe.put("RegionName", "eu-west-3"); + assertError("ResourceNotFoundException", () -> service.describeRegion(missingDescribe)); + assertError("ConflictException", () -> service.addRegion(request)); ObjectNode primaryRegion = request.deepCopy(); From afb8c467334849032ac356c01f4f810e3d753b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 16:41:30 -0400 Subject: [PATCH 53/99] feat(ssoadmin): describe trusted token issuers --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 17 +++++++++++++++++ .../services/ssoadmin/SsoAdminService.java | 4 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 16 ++++++++++++++++ .../services/ssoadmin/SsoAdminServiceTest.java | 2 ++ 6 files changed, 47 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 9457ad2109..1b6c6242b2 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -294,6 +294,13 @@ void createTrustedTokenIssuerUsesAwsSdk() { .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); assertThat(replay.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); + var described = sso.describeTrustedTokenIssuer(request -> request + .trustedTokenIssuerArn(created.trustedTokenIssuerArn())); + assertThat(described.name()).isEqualTo("SdkIssuer"); + assertThat(described.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); + assertThat(described.trustedTokenIssuerConfiguration().oidcJwtConfiguration().issuerUrl()) + .isEqualTo("https://issuer.example.com"); + var deleteResponse = sso.deleteTrustedTokenIssuer(request -> request .trustedTokenIssuerArn(created.trustedTokenIssuerArn())); assertThat(deleteResponse.sdkHttpResponse().isSuccessful()).isTrue(); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index f007931178..2ee318d6e2 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -18,6 +18,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeInstanceAccessControlAttributeConfiguration` | Returns the IAM Identity Center ABAC attribute configuration and its creation status for an instance. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | +| `DescribeTrustedTokenIssuer` | Returns the trusted token issuer name, ARN, OIDC JWT configuration, and issuer type. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index c5fd2f4526..1d3c75b8da 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -36,6 +36,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeInstanceAccessControlAttributeConfiguration" -> describeInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); + case "DescribeTrustedTokenIssuer" -> describeTrustedTokenIssuer(request); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); case "DescribeRegion" -> describeRegion(request); @@ -172,6 +173,22 @@ private Response createTrustedTokenIssuer(JsonNode request, String callerAccount .put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn())).build(); } + private Response describeTrustedTokenIssuer(JsonNode request) { + var issuer = service.describeTrustedTokenIssuer(request); + ObjectNode response = mapper.createObjectNode(); + response.put("Name", issuer.name()); + response.put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn()); + ObjectNode configuration = response.putObject("TrustedTokenIssuerConfiguration"); + var oidc = issuer.oidcJwtConfiguration(); + ObjectNode oidcNode = configuration.putObject("OidcJwtConfiguration"); + oidcNode.put("ClaimAttributePath", oidc.claimAttributePath()); + oidcNode.put("IdentityStoreAttributePath", oidc.identityStoreAttributePath()); + oidcNode.put("IssuerUrl", oidc.issuerUrl()); + oidcNode.put("JwksRetrievalOption", oidc.jwksRetrievalOption()); + response.put("TrustedTokenIssuerType", issuer.trustedTokenIssuerType()); + return Response.ok(response).build(); + } + private Response deleteTrustedTokenIssuer(JsonNode request) { service.deleteTrustedTokenIssuer(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 96c6c1b52f..6adc7fd45f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -317,6 +317,10 @@ public synchronized TrustedTokenIssuer createTrustedTokenIssuer(JsonNode request return issuer; } + public TrustedTokenIssuer describeTrustedTokenIssuer(JsonNode request) { + return getTrustedTokenIssuer(required(request, "TrustedTokenIssuerArn")); + } + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { validateTrustedTokenIssuerArn(trustedTokenIssuerArn); return trustedTokenIssuers.get(trustedTokenIssuerArn) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 9782fd85bc..fae3699ffb 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -351,6 +351,22 @@ void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { .then() .statusCode(200) .body("TrustedTokenIssuerArn", equalTo(arn)); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeTrustedTokenIssuer") + .body("{\"TrustedTokenIssuerArn\":\"" + arn + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Name", equalTo("IntegrationIssuer")) + .body("TrustedTokenIssuerArn", equalTo(arn)) + .body("TrustedTokenIssuerType", equalTo("OIDC_JWT")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.ClaimAttributePath", equalTo("sub")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IdentityStoreAttributePath", equalTo("userName")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IssuerUrl", equalTo("https://issuer.example.com")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.JwksRetrievalOption", equalTo("OPEN_ID_DISCOVERY")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 65109ff386..b4d18c7dd2 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -87,6 +87,8 @@ void createTrustedTokenIssuerPersistsOidcConfigurationAndSupportsIdempotency() { assertEquals("https://issuer.example.com", created.oidcJwtConfiguration().issuerUrl()); assertEquals(created, service.createTrustedTokenIssuer(request, ACCOUNT_ID)); assertEquals(created, service.getTrustedTokenIssuer(created.trustedTokenIssuerArn())); + ObjectNode describe = mapper.createObjectNode().put("TrustedTokenIssuerArn", created.trustedTokenIssuerArn()); + assertEquals(created, service.describeTrustedTokenIssuer(describe)); ObjectNode mismatch = request.deepCopy(); mismatch.put("Name", "IssuerTwo"); From d7f575ce63c45a2dae12aba4a4d1b66d05085313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 16:49:36 -0400 Subject: [PATCH 54/99] feat(ssoadmin): describe application providers --- .../floci/test/SsoAdminAccountAssignmentTest.java | 5 +++++ docs/services/ssoadmin.md | 1 + .../services/ssoadmin/SsoAdminJsonHandler.java | 9 +++++++++ .../floci/services/ssoadmin/SsoAdminService.java | 11 +++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 14 ++++++++++++++ .../services/ssoadmin/SsoAdminServiceTest.java | 15 +++++++++++++++ 6 files changed, 55 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 1b6c6242b2..c9e56bf540 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -143,6 +143,11 @@ void createApplicationUsesAwsSdk() { assertThat(described.statusAsString()).isEqualTo("DISABLED"); assertThat(described.portalOptions().visibilityAsString()).isEqualTo("ENABLED"); assertThat(described.portalOptions().signInOptions().originAsString()).isEqualTo("APPLICATION"); + + var provider = sso.describeApplicationProvider(request -> request + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom")); + assertThat(provider.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); + assertThat(provider.federationProtocolAsString()).isEqualTo("OAUTH"); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 2ee318d6e2..fffd24796d 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -26,6 +26,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | +| `DescribeApplicationProvider` | Returns the supported custom OAuth application provider metadata with AWS-compatible ARN validation and not-found behavior. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 1d3c75b8da..e606f4cd99 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -44,6 +44,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeApplication" -> describeApplication(request); case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); + case "DescribeApplicationProvider" -> describeApplicationProvider(request); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); @@ -233,6 +234,14 @@ private Response describeApplicationAssignment(JsonNode request) { return Response.ok(response).build(); } + private Response describeApplicationProvider(JsonNode request) { + String applicationProviderArn = service.describeApplicationProvider(request); + ObjectNode response = mapper.createObjectNode(); + response.put("ApplicationProviderArn", applicationProviderArn); + response.put("FederationProtocol", "OAUTH"); + return Response.ok(response).build(); + } + private Response deleteApplicationAssignment(JsonNode request) { service.deleteApplicationAssignment(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6adc7fd45f..5f009ade81 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -578,6 +578,17 @@ public ApplicationAssignment describeApplicationAssignment(JsonNode request) { .orElseThrow(() -> notFound("Application assignment not found for the specified principal.")); } + public String describeApplicationProvider(JsonNode request) { + String applicationProviderArn = required(request, "ApplicationProviderArn"); + if (applicationProviderArn.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(applicationProviderArn).matches()) { + throw validation("ApplicationProviderArn is invalid."); + } + if (!CUSTOM_APPLICATION_PROVIDER_ARN.equals(applicationProviderArn)) { + throw notFound("Application provider not found: " + applicationProviderArn); + } + return applicationProviderArn; + } + public synchronized void deleteApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index fae3699ffb..3afecc8c77 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -226,6 +226,20 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body("PrincipalType", equalTo("USER")); } + @Test + void describeApplicationProviderReturnsCustomOauthProvider() { + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeApplicationProvider") + .body("{\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"}") + .when().post("/") + .then() + .statusCode(200) + .body("ApplicationProviderArn", equalTo("arn:aws:sso::aws:applicationProvider/custom")) + .body("FederationProtocol", equalTo("OAUTH")); + } + @Test void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index b4d18c7dd2..94ab81a9da 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -478,6 +478,21 @@ void describeApplicationAssignmentReturnsDirectAssignmentAndValidatesRequest() { assertError("ValidationException", () -> service.describeApplicationAssignment(invalidPrincipal)); } + @Test + void describeApplicationProviderReturnsCustomOauthProviderAndValidatesArn() { + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + assertEquals("arn:aws:sso::aws:applicationProvider/custom", service.describeApplicationProvider(request)); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/example"); + assertError("ResourceNotFoundException", () -> service.describeApplicationProvider(missing)); + + ObjectNode malformed = mapper.createObjectNode(); + malformed.put("ApplicationProviderArn", "not-an-arn"); + assertError("ValidationException", () -> service.describeApplicationProvider(malformed)); + } + @Test void deleteApplicationAssignmentRevokesDirectAssignmentAndValidatesPrincipal() { SsoApplication application = createApplication("Delete Assignment App", "delete-assignment-app-token"); From c0d850e9aa8e76226fb123118d3a626a6ac09d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 16:54:11 -0400 Subject: [PATCH 55/99] feat(ssoadmin): list application assignments --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 17 ++++++++++ .../services/ssoadmin/SsoAdminService.java | 13 ++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 12 +++++++ .../ssoadmin/SsoAdminServiceTest.java | 31 +++++++++++++++++++ 6 files changed, 81 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c9e56bf540..7213597383 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -178,6 +178,13 @@ void createApplicationAssignmentUsesAwsSdk() { assertThat(described.principalId()).isEqualTo("11111111-2222-3333-4444-555555555555"); assertThat(described.principalTypeAsString()).isEqualTo("USER"); + var listed = sso.listApplicationAssignments(request -> request.applicationArn(applicationArn).maxResults(1)); + assertThat(listed.applicationAssignments()).singleElement().satisfies(assignment -> { + assertThat(assignment.applicationArn()).isEqualTo(applicationArn); + assertThat(assignment.principalId()).isEqualTo("11111111-2222-3333-4444-555555555555"); + assertThat(assignment.principalTypeAsString()).isEqualTo("USER"); + }); + assertThatThrownBy(() -> sso.createApplicationAssignment(request -> request .applicationArn(applicationArn) .principalId("11111111-2222-3333-4444-555555555555") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index fffd24796d..d04a687d4a 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -27,6 +27,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | | `DescribeApplicationProvider` | Returns the supported custom OAuth application provider metadata with AWS-compatible ARN validation and not-found behavior. | +| `ListApplicationAssignments` | Lists direct user and group assignments for an IAM Identity Center application with AWS-compatible pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index e606f4cd99..2ca90c7503 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -45,6 +45,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); case "DescribeApplicationProvider" -> describeApplicationProvider(request); + case "ListApplicationAssignments" -> listApplicationAssignments(request); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); @@ -242,6 +243,22 @@ private Response describeApplicationProvider(JsonNode request) { return Response.ok(response).build(); } + private Response listApplicationAssignments(JsonNode request) { + var page = service.listApplicationAssignments(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode assignments = response.putArray("ApplicationAssignments"); + page.items().forEach(assignment -> { + ObjectNode node = assignments.addObject(); + node.put("ApplicationArn", assignment.applicationArn()); + node.put("PrincipalId", assignment.principalId()); + node.put("PrincipalType", assignment.principalType()); + }); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response deleteApplicationAssignment(JsonNode request) { service.deleteApplicationAssignment(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 5f009ade81..3e0eca72ba 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -589,6 +589,19 @@ public String describeApplicationProvider(JsonNode request) { return applicationProviderArn; } + public PaginatedResult listApplicationAssignments(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + List matching = applicationAssignments.scan(key -> true).stream() + .filter(assignment -> applicationArn.equals(assignment.applicationArn())) + .sorted(Comparator.comparing(ApplicationAssignment::principalType) + .thenComparing(ApplicationAssignment::principalId)) + .toList(); + return Pagination.paginate(matching, + assignment -> assignment.principalType() + ":" + assignment.principalId(), + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + public synchronized void deleteApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 3afecc8c77..5861791c3c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -224,6 +224,18 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body("ApplicationArn", equalTo(applicationArn)) .body("PrincipalId", equalTo("11111111-2222-3333-4444-555555555555")) .body("PrincipalType", equalTo("USER")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationAssignments") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"MaxResults\":1}") + .when().post("/") + .then() + .statusCode(200) + .body("ApplicationAssignments[0].ApplicationArn", equalTo(applicationArn)) + .body("ApplicationAssignments[0].PrincipalId", equalTo("11111111-2222-3333-4444-555555555555")) + .body("ApplicationAssignments[0].PrincipalType", equalTo("USER")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 94ab81a9da..67e02e1ae5 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -27,6 +27,7 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -493,6 +494,36 @@ void describeApplicationProviderReturnsCustomOauthProviderAndValidatesArn() { assertError("ValidationException", () -> service.describeApplicationProvider(malformed)); } + @Test + void listApplicationAssignmentsPaginatesAndScopesToApplication() { + SsoApplication application = createApplication("List Assignment App", "list-assignment-app-token"); + ObjectNode user = mapper.createObjectNode(); + user.put("ApplicationArn", application.applicationArn()); + user.put("PrincipalId", "11111111-2222-3333-4444-555555555555"); + user.put("PrincipalType", "USER"); + service.createApplicationAssignment(user); + ObjectNode group = user.deepCopy(); + group.put("PrincipalId", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + group.put("PrincipalType", "GROUP"); + service.createApplicationAssignment(group); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("MaxResults", 1); + var first = service.listApplicationAssignments(request); + assertEquals(1, first.items().size()); + assertNotNull(first.nextToken()); + + request.put("NextToken", first.nextToken()); + var second = service.listApplicationAssignments(request); + assertEquals(1, second.items().size()); + assertNull(second.nextToken()); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationArn", "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.listApplicationAssignments(missing)); + } + @Test void deleteApplicationAssignmentRevokesDirectAssignmentAndValidatesPrincipal() { SsoApplication application = createApplication("Delete Assignment App", "delete-assignment-app-token"); From 7d0cdb96c5274675c7ad095e55e4c6975bffd45c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:04:07 -0400 Subject: [PATCH 56/99] feat(ssoadmin): list principal application assignments --- .../test/SsoAdminAccountAssignmentTest.java | 15 +++++ docs/services/ssoadmin.md | 1 + .../identitystore/IdentityStoreService.java | 11 ++++ .../ssoadmin/SsoAdminJsonHandler.java | 11 ++++ .../services/ssoadmin/SsoAdminService.java | 55 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 35 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 31 +++++++++++ 7 files changed, 159 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 7213597383..c094fc5e17 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -190,6 +190,21 @@ void createApplicationAssignmentUsesAwsSdk() { .principalId("11111111-2222-3333-4444-555555555555") .principalType("USER"))) .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + + String groupId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + sso.createApplicationAssignment(request -> request + .applicationArn(applicationArn) + .principalId(groupId) + .principalType("GROUP")); + var principalAssignments = sso.listApplicationAssignmentsForPrincipal(request -> request + .instanceArn(instanceArn) + .principalId(groupId) + .principalType("GROUP")); + assertThat(principalAssignments.applicationAssignments()).singleElement().satisfies(assignment -> { + assertThat(assignment.applicationArn()).isEqualTo(applicationArn); + assertThat(assignment.principalId()).isEqualTo(groupId); + assertThat(assignment.principalTypeAsString()).isEqualTo("GROUP"); + }); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d04a687d4a..297bd67522 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -28,6 +28,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | | `DescribeApplicationProvider` | Returns the supported custom OAuth application provider metadata with AWS-compatible ARN validation and not-found behavior. | | `ListApplicationAssignments` | Lists direct user and group assignments for an IAM Identity Center application with AWS-compatible pagination. | +| `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index f44f39b0a1..9972b41f78 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java @@ -323,6 +323,17 @@ public boolean isMember(String storeId, String userId, String groupId) { && finalGroupId.equals(membership.groupId())); } + public Set groupIdsForUser(String storeId, String userId) { + storeId = requireStore(storeId); + userId = requireResourceId(userId, "UserId"); + requireUser(storeId, userId); + String finalUserId = userId; + return listMembershipsAll(storeId).stream() + .filter(membership -> finalUserId.equals(membership.userId())) + .map(Membership::groupId) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + public List validateGroupIds(JsonNode groupIds) { if (groupIds == null || !groupIds.isArray() || groupIds.size() < 1 || groupIds.size() > 100) { throw validation("GroupIds must contain between 1 and 100 identifiers."); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 2ca90c7503..1fbf2d6d5b 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.core.common.PaginatedResult; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; import io.github.hectorvent.floci.services.ssoadmin.model.AssignmentOperation; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAssignment; @@ -46,6 +47,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); case "DescribeApplicationProvider" -> describeApplicationProvider(request); case "ListApplicationAssignments" -> listApplicationAssignments(request); + case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); @@ -245,6 +247,15 @@ private Response describeApplicationProvider(JsonNode request) { private Response listApplicationAssignments(JsonNode request) { var page = service.listApplicationAssignments(request); + return applicationAssignmentsResponse(page); + } + + private Response listApplicationAssignmentsForPrincipal(JsonNode request, String callerAccountId) { + var page = service.listApplicationAssignmentsForPrincipal(request, callerAccountId); + return applicationAssignmentsResponse(page); + } + + private Response applicationAssignmentsResponse(PaginatedResult page) { ObjectNode response = mapper.createObjectNode(); ArrayNode assignments = response.putArray("ApplicationAssignments"); page.items().forEach(assignment -> { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 3e0eca72ba..ab892ce98c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -602,6 +602,61 @@ public PaginatedResult listApplicationAssignments(JsonNod optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + public PaginatedResult listApplicationAssignmentsForPrincipal(JsonNode request, + String callerAccountId) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + validateAccountId(callerAccountId); + if (instance.accountInstance() && !callerAccountId.equals(instance.ownerAccountId())) { + throw accessDenied("The account instance belongs to a different account."); + } + String principalId = validatePrincipalId(required(request, "PrincipalId")); + String principalType = required(request, "PrincipalType"); + if (!PRINCIPAL_TYPES.contains(principalType)) { + throw validation("PrincipalType must be USER or GROUP."); + } + + String applicationFilter = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() != 1 || !filter.has("ApplicationArn")) { + throw validation("Filter must contain exactly one ApplicationArn."); + } + applicationFilter = validateApplicationArn(required(filter, "ApplicationArn")); + SsoApplication filteredApplication = getApplication(applicationFilter); + if (!instanceArn.equals(filteredApplication.instanceArn())) { + throw notFound("Application not found in the specified IAM Identity Center instance."); + } + } + if (!instance.accountInstance() && !callerAccountId.equals(instance.ownerAccountId()) && applicationFilter == null) { + throw accessDenied("Filter.ApplicationArn is required from a member account against an organization instance."); + } + + Set effectiveGroupIds = Set.of(); + if ("USER".equals(principalType)) { + effectiveGroupIds = identityStoreService.groupIdsForUser(instance.identityStoreId(), principalId); + } + Set finalEffectiveGroupIds = effectiveGroupIds; + String finalApplicationFilter = applicationFilter; + List effective = applicationAssignments.scan(key -> true).stream() + .filter(assignment -> { + SsoApplication application = applications.get(assignment.applicationArn()).orElse(null); + return application != null && instanceArn.equals(application.instanceArn()); + }) + .filter(assignment -> finalApplicationFilter == null + || finalApplicationFilter.equals(assignment.applicationArn())) + .filter(assignment -> (principalId.equals(assignment.principalId()) + && principalType.equals(assignment.principalType())) + || ("USER".equals(principalType) && "GROUP".equals(assignment.principalType()) + && finalEffectiveGroupIds.contains(assignment.principalId()))) + .map(assignment -> new ApplicationAssignment(assignment.applicationArn(), principalId, principalType)) + .distinct() + .sorted(Comparator.comparing(ApplicationAssignment::applicationArn)) + .toList(); + return Pagination.paginate(effective, ApplicationAssignment::applicationArn, + optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); + } + public synchronized void deleteApplicationAssignment(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 5861791c3c..5ebecd4161 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -238,6 +238,41 @@ void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { .body("ApplicationAssignments[0].PrincipalType", equalTo("USER")); } + @Test + void listApplicationAssignmentsForPrincipalReturnsDirectGroupAssignments() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Principal List Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + String groupId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplicationAssignment") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"PrincipalId\":\"" + groupId + + "\",\"PrincipalType\":\"GROUP\"}") + .when().post("/") + .then().statusCode(200); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationAssignmentsForPrincipal") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"PrincipalId\":\"" + groupId + "\",\"PrincipalType\":\"GROUP\"}") + .when().post("/") + .then().statusCode(200) + .body("ApplicationAssignments[0].ApplicationArn", equalTo(applicationArn)) + .body("ApplicationAssignments[0].PrincipalId", equalTo(groupId)) + .body("ApplicationAssignments[0].PrincipalType", equalTo("GROUP")); + } + @Test void describeApplicationProviderReturnsCustomOauthProvider() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 67e02e1ae5..c9daaa7196 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Set; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -524,6 +526,35 @@ void listApplicationAssignmentsPaginatesAndScopesToApplication() { assertError("ResourceNotFoundException", () -> service.listApplicationAssignments(missing)); } + @Test + void listApplicationAssignmentsForPrincipalIncludesGroupBasedUserAccessAndRequiresMemberFilter() { + SsoApplication application = createApplication("Principal Assignment App", "principal-assignment-app-token"); + String groupId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + ObjectNode groupAssignment = mapper.createObjectNode(); + groupAssignment.put("ApplicationArn", application.applicationArn()); + groupAssignment.put("PrincipalId", groupId); + groupAssignment.put("PrincipalType", "GROUP"); + service.createApplicationAssignment(groupAssignment); + org.mockito.Mockito.when(identityStoreService.groupIdsForUser(service.getIdentityStoreId(), PRINCIPAL_ID)) + .thenReturn(Set.of(groupId)); + + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("PrincipalId", PRINCIPAL_ID); + request.put("PrincipalType", "USER"); + var page = service.listApplicationAssignmentsForPrincipal(request, ACCOUNT_ID); + assertEquals(1, page.items().size()); + assertEquals(application.applicationArn(), page.items().get(0).applicationArn()); + assertEquals(PRINCIPAL_ID, page.items().get(0).principalId()); + assertEquals("USER", page.items().get(0).principalType()); + + ObjectNode memberRequest = request.deepCopy(); + assertError("AccessDeniedException", + () -> service.listApplicationAssignmentsForPrincipal(memberRequest, "222233334444")); + memberRequest.putObject("Filter").put("ApplicationArn", application.applicationArn()); + assertEquals(1, service.listApplicationAssignmentsForPrincipal(memberRequest, "222233334444").items().size()); + } + @Test void deleteApplicationAssignmentRevokesDirectAssignmentAndValidatesPrincipal() { SsoApplication application = createApplication("Delete Assignment App", "delete-assignment-app-token"); From 284017d2578df79cfdcf0a9ef159cd9d8ece74a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:16:55 -0400 Subject: [PATCH 57/99] feat(ssoadmin): list applications --- .../test/SsoAdminAccountAssignmentTest.java | 8 +++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 ++++++- .../services/ssoadmin/SsoAdminService.java | 56 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 11 ++++ .../ssoadmin/SsoAdminServiceTest.java | 27 +++++++++ 6 files changed, 120 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index c094fc5e17..3c56c6c1a8 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -148,6 +148,14 @@ void createApplicationUsesAwsSdk() { .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom")); assertThat(provider.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); assertThat(provider.federationProtocolAsString()).isEqualTo("OAUTH"); + + var applications = sso.listApplications(request -> request + .instanceArn(instanceArn) + .filter(filter -> filter + .applicationAccount("000000000000") + .applicationProvider("arn:aws:sso::aws:applicationProvider/custom"))); + assertThat(applications.applications()).anySatisfy(application -> + assertThat(application.applicationArn()).isEqualTo(created.applicationArn())); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 297bd67522..a800b615bd 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -24,6 +24,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | +| `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | | `DescribeApplicationProvider` | Returns the supported custom OAuth application provider metadata with AWS-compatible ARN validation and not-found behavior. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 1fbf2d6d5b..0b7db86b7d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -43,6 +43,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeRegion" -> describeRegion(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); + case "ListApplications" -> listApplications(request, callerAccountId); case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); case "DescribeApplicationProvider" -> describeApplicationProvider(request); @@ -295,7 +296,21 @@ private Response createApplication(JsonNode request, String callerAccountId, Str } private Response describeApplication(JsonNode request) { - SsoApplication application = service.describeApplication(request); + return Response.ok(applicationNode(service.describeApplication(request))).build(); + } + + private Response listApplications(JsonNode request, String callerAccountId) { + var page = service.listApplications(request, callerAccountId); + ObjectNode response = mapper.createObjectNode(); + ArrayNode applications = response.putArray("Applications"); + page.items().forEach(application -> applications.add(applicationNode(application))); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + + private ObjectNode applicationNode(SsoApplication application) { ObjectNode response = mapper.createObjectNode(); response.put("ApplicationAccount", application.applicationAccount()); response.put("ApplicationArn", application.applicationArn()); @@ -322,7 +337,7 @@ private Response describeApplication(JsonNode request) { } } response.put("Status", application.status()); - return Response.ok(response).build(); + return response; } private Response listPermissionSets(JsonNode request) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ab892ce98c..6ea16e7f66 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1447,6 +1447,62 @@ public SsoApplication describeApplication(JsonNode request) { return getApplication(validateApplicationArn(required(request, "ApplicationArn"))); } + public PaginatedResult listApplications(JsonNode request, String callerAccountId) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + validateAccountId(callerAccountId); + if (instance.accountInstance() && !callerAccountId.equals(instance.ownerAccountId())) { + throw accessDenied("The account instance belongs to a different account."); + } + + String applicationAccount = null; + String applicationProvider = null; + JsonNode filter = request == null ? null : request.get("Filter"); + if (filter != null && !filter.isNull()) { + if (!filter.isObject() || filter.size() > 2) { + throw validation("Filter may contain only ApplicationAccount and ApplicationProvider."); + } + for (java.util.Iterator fields = filter.fieldNames(); fields.hasNext();) { + String field = fields.next(); + if (!Set.of("ApplicationAccount", "ApplicationProvider").contains(field)) { + throw validation("Filter may contain only ApplicationAccount and ApplicationProvider."); + } + } + if (filter.has("ApplicationAccount")) { + applicationAccount = validateAccountId(required(filter, "ApplicationAccount")); + } + if (filter.has("ApplicationProvider")) { + applicationProvider = required(filter, "ApplicationProvider"); + if (applicationProvider.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(applicationProvider).matches()) { + throw validation("Filter.ApplicationProvider is invalid."); + } + } + } + if (!instance.accountInstance() && !callerAccountId.equals(instance.ownerAccountId())) { + if (applicationAccount == null || !callerAccountId.equals(applicationAccount)) { + throw accessDenied("Filter.ApplicationAccount must match the member account when listing organization-instance applications."); + } + } + + String finalApplicationAccount = applicationAccount; + String finalApplicationProvider = applicationProvider; + List matching = applications.scan(key -> true).stream() + .filter(application -> instanceArn.equals(application.instanceArn())) + .filter(application -> finalApplicationAccount == null + || finalApplicationAccount.equals(application.applicationAccount())) + .filter(application -> finalApplicationProvider == null + || finalApplicationProvider.equals(application.applicationProviderArn())) + .sorted(Comparator.comparing(SsoApplication::applicationArn)) + .toList(); + Integer requested = optionalMaxResults(request); + if (requested != null && requested > 100) { + throw validation("MaxResults must be between 1 and 100."); + } + Integer effectivePageSize = requested == null ? null : Math.min(requested, 50); + return Pagination.paginate(matching, SsoApplication::applicationArn, + effectivePageSize, text(request, "NextToken"), 50, 50, "ValidationException"); + } + SsoApplication getApplication(String applicationArn) { validateApplicationArn(applicationArn); return applications.get(applicationArn).orElseThrow(() -> notFound("Application not found: " + applicationArn)); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 5ebecd4161..484f8943a2 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -183,6 +183,17 @@ void describeApplicationReturnsAwsApplicationShape() { .body("PortalOptions.SignInOptions.Origin", equalTo("APPLICATION")) .body("PortalOptions.SignInOptions.ApplicationUrl", equalTo("https://example.com/login")) .body("Status", equalTo("DISABLED")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplications") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"Filter\":{\"ApplicationAccount\":\"000000000000\"," + + "\"ApplicationProvider\":\"arn:aws:sso::aws:applicationProvider/custom\"}}") + .when().post("/") + .then().statusCode(200) + .body("Applications.ApplicationArn", org.hamcrest.Matchers.hasItem(applicationArn)); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index c9daaa7196..9c4d058699 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -347,6 +347,33 @@ void describeApplicationReturnsPersistedApplicationAndValidatesArn() { assertError("ValidationException", () -> service.describeApplication(malformed)); } + @Test + void listApplicationsSupportsFiltersPaginationAndMemberAccountIsolation() { + SsoApplication first = createApplication("List Apps One", "list-apps-one-token"); + SsoApplication second = createApplication("List Apps Two", "list-apps-two-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("MaxResults", 1); + var firstPage = service.listApplications(request, ACCOUNT_ID); + assertEquals(1, firstPage.items().size()); + assertNotNull(firstPage.nextToken()); + request.put("NextToken", firstPage.nextToken()); + assertEquals(1, service.listApplications(request, ACCOUNT_ID).items().size()); + + ObjectNode providerFilter = mapper.createObjectNode(); + providerFilter.put("InstanceArn", service.getInstanceArn()); + providerFilter.putObject("Filter") + .put("ApplicationProvider", "arn:aws:sso::aws:applicationProvider/custom") + .put("ApplicationAccount", ACCOUNT_ID); + assertTrue(service.listApplications(providerFilter, ACCOUNT_ID).items().containsAll(java.util.List.of(first, second))); + + ObjectNode memberWithoutFilter = mapper.createObjectNode().put("InstanceArn", service.getInstanceArn()); + assertError("AccessDeniedException", + () -> service.listApplications(memberWithoutFilter, "222233334444")); + memberWithoutFilter.putObject("Filter").put("ApplicationAccount", "222233334444"); + assertTrue(service.listApplications(memberWithoutFilter, "222233334444").items().isEmpty()); + } + @Test void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { ObjectNode createRequest = mapper.createObjectNode(); From 6d36dfd3fed58f3ae46cff786646e6c4cb513aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:21:41 -0400 Subject: [PATCH 58/99] feat(ssoadmin): list application providers --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 +++++++++++++++++-- .../services/ssoadmin/SsoAdminService.java | 9 +++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 15 +++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 11 +++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 3c56c6c1a8..051c984836 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -149,6 +149,13 @@ void createApplicationUsesAwsSdk() { assertThat(provider.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); assertThat(provider.federationProtocolAsString()).isEqualTo("OAUTH"); + var providers = sso.listApplicationProviders(request -> {}); + assertThat(providers.applicationProviders()).singleElement().satisfies(applicationProvider -> { + assertThat(applicationProvider.applicationProviderArn()) + .isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); + assertThat(applicationProvider.federationProtocolAsString()).isEqualTo("OAUTH"); + }); + var applications = sso.listApplications(request -> request .instanceArn(instanceArn) .filter(filter -> filter diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index a800b615bd..cf17dfac4d 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -28,6 +28,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `CreateApplicationAssignment` | Grants direct application access to a user or group. | | `DescribeApplicationAssignment` | Retrieves a direct user or group assignment to an IAM Identity Center application with AWS-compatible validation. | | `DescribeApplicationProvider` | Returns the supported custom OAuth application provider metadata with AWS-compatible ARN validation and not-found behavior. | +| `ListApplicationProviders` | Lists supported IAM Identity Center application providers with AWS-compatible pagination and provider metadata. | | `ListApplicationAssignments` | Lists direct user and group assignments for an IAM Identity Center application with AWS-compatible pagination. | | `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 0b7db86b7d..6ec00b1050 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -47,6 +47,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); case "DescribeApplicationProvider" -> describeApplicationProvider(request); + case "ListApplicationProviders" -> listApplicationProviders(request); case "ListApplicationAssignments" -> listApplicationAssignments(request); case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); @@ -239,11 +240,25 @@ private Response describeApplicationAssignment(JsonNode request) { } private Response describeApplicationProvider(JsonNode request) { - String applicationProviderArn = service.describeApplicationProvider(request); + return Response.ok(applicationProviderNode(service.describeApplicationProvider(request))).build(); + } + + private Response listApplicationProviders(JsonNode request) { + var page = service.listApplicationProviders(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode providers = response.putArray("ApplicationProviders"); + page.items().forEach(provider -> providers.add(applicationProviderNode(provider))); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + + private ObjectNode applicationProviderNode(String applicationProviderArn) { ObjectNode response = mapper.createObjectNode(); response.put("ApplicationProviderArn", applicationProviderArn); response.put("FederationProtocol", "OAUTH"); - return Response.ok(response).build(); + return response; } private Response listApplicationAssignments(JsonNode request) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6ea16e7f66..42d4dec48d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -589,6 +589,15 @@ public String describeApplicationProvider(JsonNode request) { return applicationProviderArn; } + public PaginatedResult listApplicationProviders(JsonNode request) { + Integer requested = optionalMaxResults(request); + if (requested != null && requested > 100) { + throw validation("MaxResults must be between 1 and 100."); + } + return Pagination.paginate(java.util.List.of(CUSTOM_APPLICATION_PROVIDER_ARN), value -> value, + requested, text(request, "NextToken"), 50, 100, "ValidationException"); + } + public PaginatedResult listApplicationAssignments(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 484f8943a2..39337fa9c3 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -298,6 +298,21 @@ void describeApplicationProviderReturnsCustomOauthProvider() { .body("FederationProtocol", equalTo("OAUTH")); } + @Test + void listApplicationProvidersReturnsCustomOauthProvider() { + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationProviders") + .body("{}") + .when().post("/") + .then() + .statusCode(200) + .body("ApplicationProviders[0].ApplicationProviderArn", + equalTo("arn:aws:sso::aws:applicationProvider/custom")) + .body("ApplicationProviders[0].FederationProtocol", equalTo("OAUTH")); + } + @Test void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 9c4d058699..401fa6ce09 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -523,6 +523,17 @@ void describeApplicationProviderReturnsCustomOauthProviderAndValidatesArn() { assertError("ValidationException", () -> service.describeApplicationProvider(malformed)); } + @Test + void listApplicationProvidersReturnsCustomProviderAndValidatesPagination() { + ObjectNode request = mapper.createObjectNode(); + var page = service.listApplicationProviders(request); + assertEquals(java.util.List.of("arn:aws:sso::aws:applicationProvider/custom"), page.items()); + assertEquals(null, page.nextToken()); + + ObjectNode invalidMaxResults = mapper.createObjectNode().put("MaxResults", 101); + assertError("ValidationException", () -> service.listApplicationProviders(invalidMaxResults)); + } + @Test void listApplicationAssignmentsPaginatesAndScopesToApplication() { SsoApplication application = createApplication("List Assignment App", "list-assignment-app-token"); From d392f94ddfe6cb9d81d4355c11769337394bcf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:26:08 -0400 Subject: [PATCH 59/99] feat(ssoadmin): list regions --- .../test/SsoAdminAccountAssignmentTest.java | 6 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 19 +++++++++++++++++-- .../services/ssoadmin/SsoAdminService.java | 6 ++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 12 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 9 +++++++++ 6 files changed, 51 insertions(+), 2 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 051c984836..dd8c844a42 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -35,6 +35,12 @@ void addRegionUsesAwsSdk() { assertThat(described.isPrimaryRegion()).isFalse(); assertThat(described.addedDate()).isNotNull(); + var regions = sso.listRegions(request -> request.instanceArn(instanceArn)); + assertThat(regions.regions()).anySatisfy(region -> { + assertThat(region.regionName()).isEqualTo("ap-southeast-3"); + assertThat(region.statusAsString()).isEqualTo("ACTIVE"); + }); + assertThatThrownBy(() -> sso.addRegion(request -> request .instanceArn(instanceArn) .regionName("ap-southeast-3"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index cf17dfac4d..08f70a9830 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -22,6 +22,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | +| `ListRegions` | Lists all enabled IAM Identity Center Regions, including primary and in-progress lifecycle states, with AWS-compatible pagination. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 6ec00b1050..eaeba2e565 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -41,6 +41,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); case "DescribeRegion" -> describeRegion(request); + case "ListRegions" -> listRegions(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "ListApplications" -> listApplications(request, callerAccountId); @@ -206,13 +207,27 @@ private Response addRegion(JsonNode request) { } private Response describeRegion(JsonNode request) { - var region = service.describeRegion(request); + return Response.ok(regionNode(service.describeRegion(request))).build(); + } + + private Response listRegions(JsonNode request) { + var page = service.listRegions(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode regions = response.putArray("Regions"); + page.items().forEach(region -> regions.add(regionNode(region))); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + + private ObjectNode regionNode(io.github.hectorvent.floci.services.ssoadmin.model.RegionMetadata region) { ObjectNode response = mapper.createObjectNode(); response.put("AddedDate", service.regionAddedDateEpochSeconds(region)); response.put("IsPrimaryRegion", region.primaryRegion()); response.put("RegionName", region.regionName()); response.put("Status", region.status()); - return Response.ok(response).build(); + return response; } private Response deleteApplication(JsonNode request) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 42d4dec48d..ad016eab49 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -802,6 +802,12 @@ public RegionMetadata describeRegion(JsonNode request) { .orElseThrow(() -> notFound("Region is not enabled for this IAM Identity Center instance: " + regionName)); } + public PaginatedResult listRegions(JsonNode request) { + SsoInstance instance = requireInstance(required(request, "InstanceArn")); + return Pagination.paginate(listRegionsForInstance(instance), RegionMetadata::regionName, + optionalMaxResults(request), text(request, "NextToken"), 100, 100, "ValidationException"); + } + public synchronized RegionMetadata addRegion(JsonNode request) { requireInstance(required(request, "InstanceArn")); String regionName = validateRegionName(required(request, "RegionName")); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 39337fa9c3..9a95065f1f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -78,6 +78,18 @@ void addRegionReturnsAddingAndRejectsDuplicate() { .body("IsPrimaryRegion", equalTo(false)) .body("AddedDate", org.hamcrest.Matchers.notNullValue()); + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListRegions") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"}") + .when() + .post("/") + .then() + .statusCode(200) + .body("Regions.RegionName", org.hamcrest.Matchers.hasItem("eu-west-2")) + .body("Regions.find { it.RegionName == 'eu-west-2' }.Status", equalTo("ACTIVE")); + given() .contentType("application/x-amz-json-1.1") .header("Authorization", AUTH_HEADER) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 401fa6ce09..f24d989454 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -691,6 +691,15 @@ void addRegionValidatesAndRejectsDuplicates() { assertEquals("ACTIVE", described.status()); assertFalse(described.primaryRegion()); + ObjectNode listRequest = mapper.createObjectNode(); + listRequest.put("InstanceArn", service.getInstanceArn()); + listRequest.put("MaxResults", 1); + var firstPage = service.listRegions(listRequest); + assertEquals(1, firstPage.items().size()); + assertNotNull(firstPage.nextToken()); + listRequest.put("NextToken", firstPage.nextToken()); + assertEquals(1, service.listRegions(listRequest).items().size()); + ObjectNode primaryDescribe = mapper.createObjectNode(); primaryDescribe.put("InstanceArn", service.getInstanceArn()); primaryDescribe.put("RegionName", "us-east-1"); From 9972f17569e94e7c65a8fe625d99bc3b5e0af5f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:30:02 -0400 Subject: [PATCH 60/99] feat(ssoadmin): list trusted token issuers --- .../test/SsoAdminAccountAssignmentTest.java | 7 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 25 ++++++++++++++++--- .../services/ssoadmin/SsoAdminService.java | 11 ++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 12 +++++++++ .../ssoadmin/SsoAdminServiceTest.java | 3 +++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index dd8c844a42..958c26f308 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -349,6 +349,13 @@ void createTrustedTokenIssuerUsesAwsSdk() { assertThat(described.trustedTokenIssuerConfiguration().oidcJwtConfiguration().issuerUrl()) .isEqualTo("https://issuer.example.com"); + var issuers = sso.listTrustedTokenIssuers(request -> request.instanceArn(instanceArn)); + assertThat(issuers.trustedTokenIssuers()).anySatisfy(issuer -> { + assertThat(issuer.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); + assertThat(issuer.name()).isEqualTo("SdkIssuer"); + assertThat(issuer.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); + }); + var deleteResponse = sso.deleteTrustedTokenIssuer(request -> request .trustedTokenIssuerArn(created.trustedTokenIssuerArn())); assertThat(deleteResponse.sdkHttpResponse().isSuccessful()).isTrue(); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 08f70a9830..73f38a217a 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -19,6 +19,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `DescribeTrustedTokenIssuer` | Returns the trusted token issuer name, ARN, OIDC JWT configuration, and issuer type. | +| `ListTrustedTokenIssuers` | Lists trusted token issuer metadata for an IAM Identity Center instance with AWS-compatible pagination. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index eaeba2e565..a69717ec44 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -38,6 +38,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "DescribeTrustedTokenIssuer" -> describeTrustedTokenIssuer(request); + case "ListTrustedTokenIssuers" -> listTrustedTokenIssuers(request); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); case "DescribeRegion" -> describeRegion(request); @@ -182,9 +183,7 @@ private Response createTrustedTokenIssuer(JsonNode request, String callerAccount private Response describeTrustedTokenIssuer(JsonNode request) { var issuer = service.describeTrustedTokenIssuer(request); - ObjectNode response = mapper.createObjectNode(); - response.put("Name", issuer.name()); - response.put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn()); + ObjectNode response = trustedTokenIssuerMetadataNode(issuer); ObjectNode configuration = response.putObject("TrustedTokenIssuerConfiguration"); var oidc = issuer.oidcJwtConfiguration(); ObjectNode oidcNode = configuration.putObject("OidcJwtConfiguration"); @@ -192,10 +191,28 @@ private Response describeTrustedTokenIssuer(JsonNode request) { oidcNode.put("IdentityStoreAttributePath", oidc.identityStoreAttributePath()); oidcNode.put("IssuerUrl", oidc.issuerUrl()); oidcNode.put("JwksRetrievalOption", oidc.jwksRetrievalOption()); - response.put("TrustedTokenIssuerType", issuer.trustedTokenIssuerType()); return Response.ok(response).build(); } + private Response listTrustedTokenIssuers(JsonNode request) { + var page = service.listTrustedTokenIssuers(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode issuers = response.putArray("TrustedTokenIssuers"); + page.items().forEach(issuer -> issuers.add(trustedTokenIssuerMetadataNode(issuer))); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + + private ObjectNode trustedTokenIssuerMetadataNode(io.github.hectorvent.floci.services.ssoadmin.model.TrustedTokenIssuer issuer) { + ObjectNode response = mapper.createObjectNode(); + response.put("Name", issuer.name()); + response.put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn()); + response.put("TrustedTokenIssuerType", issuer.trustedTokenIssuerType()); + return response; + } + private Response deleteTrustedTokenIssuer(JsonNode request) { service.deleteTrustedTokenIssuer(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ad016eab49..f1c2be401e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -321,6 +321,17 @@ public TrustedTokenIssuer describeTrustedTokenIssuer(JsonNode request) { return getTrustedTokenIssuer(required(request, "TrustedTokenIssuerArn")); } + public PaginatedResult listTrustedTokenIssuers(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + requireInstance(instanceArn); + List matching = trustedTokenIssuers.scan(key -> true).stream() + .filter(issuer -> instanceArn.equals(issuer.instanceArn())) + .sorted(Comparator.comparing(TrustedTokenIssuer::trustedTokenIssuerArn)) + .toList(); + return Pagination.paginate(matching, TrustedTokenIssuer::trustedTokenIssuerArn, + optionalMaxResults(request), text(request, "NextToken"), 100, 100, "ValidationException"); + } + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { validateTrustedTokenIssuerArn(trustedTokenIssuerArn); return trustedTokenIssuers.get(trustedTokenIssuerArn) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 9a95065f1f..fae51d45ef 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -466,6 +466,18 @@ void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IdentityStoreAttributePath", equalTo("userName")) .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IssuerUrl", equalTo("https://issuer.example.com")) .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.JwksRetrievalOption", equalTo("OPEN_ID_DISCOVERY")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListTrustedTokenIssuers") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"}") + .when().post("/") + .then() + .statusCode(200) + .body("TrustedTokenIssuers.TrustedTokenIssuerArn", org.hamcrest.Matchers.hasItem(arn)) + .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.Name", equalTo("IntegrationIssuer")) + .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.TrustedTokenIssuerType", equalTo("OIDC_JWT")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index f24d989454..13a409f66e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -93,6 +93,9 @@ void createTrustedTokenIssuerPersistsOidcConfigurationAndSupportsIdempotency() { ObjectNode describe = mapper.createObjectNode().put("TrustedTokenIssuerArn", created.trustedTokenIssuerArn()); assertEquals(created, service.describeTrustedTokenIssuer(describe)); + ObjectNode list = mapper.createObjectNode().put("InstanceArn", service.getInstanceArn()); + assertTrue(service.listTrustedTokenIssuers(list).items().contains(created)); + ObjectNode mismatch = request.deepCopy(); mismatch.put("Name", "IssuerTwo"); assertError("IdempotentParameterMismatch", From 82d51ab04a2c26dcde9e66955178b6911ae77f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:35:54 -0400 Subject: [PATCH 61/99] feat(ssoadmin): put application access scopes --- .../test/SsoAdminAccountAssignmentTest.java | 6 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 27 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 24 +++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 21 +++++++++++++++ 6 files changed, 85 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 958c26f308..5e407c0764 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -169,6 +169,12 @@ void createApplicationUsesAwsSdk() { .applicationProvider("arn:aws:sso::aws:applicationProvider/custom"))); assertThat(applications.applications()).anySatisfy(application -> assertThat(application.applicationArn()).isEqualTo(created.applicationArn())); + + var putScope = sso.putApplicationAccessScope(request -> request + .applicationArn(created.applicationArn()) + .scope("api:read") + .authorizedTargets(instanceArn)); + assertThat(putScope.sdkHttpResponse().isSuccessful()).isTrue(); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 73f38a217a..4f0d2e34b1 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -34,6 +34,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAssignments` | Lists direct user and group assignments for an IAM Identity Center application with AWS-compatible pagination. | | `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | +| `PutApplicationAccessScope` | Creates or updates an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index a69717ec44..e81246797f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -53,6 +53,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAssignments" -> listApplicationAssignments(request); case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); + case "PutApplicationAccessScope" -> putApplicationAccessScope(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); @@ -252,6 +253,11 @@ private Response deleteApplication(JsonNode request) { return Response.ok().build(); } + private Response putApplicationAccessScope(JsonNode request) { + service.putApplicationAccessScope(request); + return Response.ok().build(); + } + private Response deleteApplicationAccessScope(JsonNode request) { service.deleteApplicationAccessScope(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f1c2be401e..3c23ffd3dc 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -62,6 +62,7 @@ public class SsoAdminService implements Resettable { private static final Pattern APPLICATION_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); private static final Pattern TRUSTED_TOKEN_ISSUER_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::\\d{12}:trustedTokenIssuer/(?:sso)?ins-[a-zA-Z0-9-.]{16}/tti-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"); private static final Pattern APPLICATION_ACCESS_SCOPE = Pattern.compile("([A-Za-z0-9_]{1,50})(:[A-Za-z0-9_]{1,50}){0,1}(:[A-Za-z0-9_]{1,50}){0,1}"); + private static final Pattern APPLICATION_ACCESS_TARGET = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso::(?:\\d{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}|:instance/(?:sso)?ins-[a-zA-Z0-9-.]{16})"); private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); @@ -562,6 +563,32 @@ public synchronized void deleteApplication(String applicationArn) { } } + public synchronized ApplicationAccessScope putApplicationAccessScope(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String scope = required(request, "Scope"); + if (!APPLICATION_ACCESS_SCOPE.matcher(scope).matches()) { + throw validation("Scope is invalid."); + } + List authorizedTargets = new ArrayList<>(); + JsonNode targets = request == null ? null : request.get("AuthorizedTargets"); + if (targets != null && !targets.isNull()) { + if (!targets.isArray() || targets.size() < 1 || targets.size() > 10) { + throw validation("AuthorizedTargets must contain between 1 and 10 ARNs."); + } + for (JsonNode target : targets) { + if (!target.isTextual() || target.textValue().length() > 100 + || !APPLICATION_ACCESS_TARGET.matcher(target.textValue()).matches()) { + throw validation("AuthorizedTargets contains an invalid ARN."); + } + authorizedTargets.add(target.textValue()); + } + } + ApplicationAccessScope accessScope = new ApplicationAccessScope(applicationArn, scope, authorizedTargets); + applicationAccessScopes.put(applicationAccessScopeKey(applicationArn, scope), accessScope); + return accessScope; + } + public synchronized void deleteApplicationAccessScope(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index fae51d45ef..debe828983 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -310,6 +310,30 @@ void describeApplicationProviderReturnsCustomOauthProvider() { .body("FederationProtocol", equalTo("OAUTH")); } + @Test + void putApplicationAccessScopeReturnsEmptyAwsResponse() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Put Scope Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationAccessScope") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"Scope\":\"api:read\"," + + "\"AuthorizedTargets\":[\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"]}") + .when().post("/") + .then().statusCode(200) + .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + } + @Test void listApplicationProvidersReturnsCustomOauthProvider() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 13a409f66e..3de898c91f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -401,6 +401,27 @@ void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { assertError("ResourceNotFoundException", () -> service.deleteApplication(application.applicationArn())); } + @Test + void putApplicationAccessScopeCreatesAndUpdatesAuthorizedTargets() { + SsoApplication application = createApplication("Put Scope App", "put-scope-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("Scope", "api:read"); + request.putArray("AuthorizedTargets").add(service.getInstanceArn()); + + ApplicationAccessScope created = service.putApplicationAccessScope(request); + assertEquals("api:read", created.scope()); + assertEquals(java.util.List.of(service.getInstanceArn()), created.authorizedTargets()); + + request.putArray("AuthorizedTargets").add(application.applicationArn()); + ApplicationAccessScope updated = service.putApplicationAccessScope(request); + assertEquals(java.util.List.of(application.applicationArn()), updated.authorizedTargets()); + + ObjectNode invalid = request.deepCopy(); + invalid.putArray("AuthorizedTargets").add("not-an-arn"); + assertError("ValidationException", () -> service.putApplicationAccessScope(invalid)); + } + @Test void deleteApplicationAccessScopeDeletesStoredScopeAndValidatesRequest() { SsoApplication application = createApplication("Scope App", "scope-app-token"); From 59e4424d20348e7b12fd4cac4282ff34befde888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 17:39:23 -0400 Subject: [PATCH 62/99] feat(ssoadmin): get application access scopes --- .../com/floci/test/SsoAdminAccountAssignmentTest.java | 5 +++++ docs/services/ssoadmin.md | 1 + .../floci/services/ssoadmin/SsoAdminJsonHandler.java | 10 ++++++++++ .../floci/services/ssoadmin/SsoAdminService.java | 11 +++++++++++ .../services/ssoadmin/SsoAdminIntegrationTest.java | 10 ++++++++++ .../floci/services/ssoadmin/SsoAdminServiceTest.java | 1 + 6 files changed, 38 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 5e407c0764..3fe2bee911 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -175,6 +175,11 @@ void createApplicationUsesAwsSdk() { .scope("api:read") .authorizedTargets(instanceArn)); assertThat(putScope.sdkHttpResponse().isSuccessful()).isTrue(); + var accessScope = sso.getApplicationAccessScope(request -> request + .applicationArn(created.applicationArn()) + .scope("api:read")); + assertThat(accessScope.scope()).isEqualTo("api:read"); + assertThat(accessScope.authorizedTargets()).containsExactly(instanceArn); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 4f0d2e34b1..c923c0c74b 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -35,6 +35,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `PutApplicationAccessScope` | Creates or updates an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | +| `GetApplicationAccessScope` | Returns an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index e81246797f..b264937ec1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -54,6 +54,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); case "PutApplicationAccessScope" -> putApplicationAccessScope(request); + case "GetApplicationAccessScope" -> getApplicationAccessScope(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); @@ -258,6 +259,15 @@ private Response putApplicationAccessScope(JsonNode request) { return Response.ok().build(); } + private Response getApplicationAccessScope(JsonNode request) { + var accessScope = service.getApplicationAccessScope(request); + ObjectNode response = mapper.createObjectNode(); + response.put("Scope", accessScope.scope()); + ArrayNode targets = response.putArray("AuthorizedTargets"); + accessScope.authorizedTargets().forEach(targets::add); + return Response.ok(response).build(); + } + private Response deleteApplicationAccessScope(JsonNode request) { service.deleteApplicationAccessScope(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 3c23ffd3dc..d58b7cee86 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -589,6 +589,17 @@ public synchronized ApplicationAccessScope putApplicationAccessScope(JsonNode re return accessScope; } + public ApplicationAccessScope getApplicationAccessScope(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String scope = required(request, "Scope"); + if (!APPLICATION_ACCESS_SCOPE.matcher(scope).matches()) { + throw validation("Scope is invalid."); + } + return applicationAccessScopes.get(applicationAccessScopeKey(applicationArn, scope)) + .orElseThrow(() -> notFound("Application access scope not found: " + scope)); + } + public synchronized void deleteApplicationAccessScope(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index debe828983..11b48271f0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -332,6 +332,16 @@ void putApplicationAccessScopeReturnsEmptyAwsResponse() { .when().post("/") .then().statusCode(200) .body(org.hamcrest.Matchers.is(org.hamcrest.Matchers.emptyOrNullString())); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationAccessScope") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"Scope\":\"api:read\"}") + .when().post("/") + .then().statusCode(200) + .body("Scope", equalTo("api:read")) + .body("AuthorizedTargets[0]", equalTo("arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 3de898c91f..0070945597 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -412,6 +412,7 @@ void putApplicationAccessScopeCreatesAndUpdatesAuthorizedTargets() { ApplicationAccessScope created = service.putApplicationAccessScope(request); assertEquals("api:read", created.scope()); assertEquals(java.util.List.of(service.getInstanceArn()), created.authorizedTargets()); + assertEquals(created, service.getApplicationAccessScope(request)); request.putArray("AuthorizedTargets").add(application.applicationArn()); ApplicationAccessScope updated = service.putApplicationAccessScope(request); From 2e7af858d6e3991fbaaf68a12701ea6aef07206f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 19:58:24 -0400 Subject: [PATCH 63/99] feat: support listing application access scopes --- .../test/SsoAdminAccountAssignmentTest.java | 25 +++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 17 ++++++ .../services/ssoadmin/SsoAdminService.java | 11 ++++ .../ssoadmin/SsoAdminIntegrationTest.java | 52 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 43 +++++++++++++++ 6 files changed, 149 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 3fe2bee911..b8e094a552 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -180,6 +180,31 @@ void createApplicationUsesAwsSdk() { .scope("api:read")); assertThat(accessScope.scope()).isEqualTo("api:read"); assertThat(accessScope.authorizedTargets()).containsExactly(instanceArn); + + sso.putApplicationAccessScope(request -> request + .applicationArn(created.applicationArn()) + .scope("api:write") + .authorizedTargets(instanceArn)); + var firstScopePage = sso.listApplicationAccessScopes(request -> request + .applicationArn(created.applicationArn()) + .maxResults(1)); + assertThat(firstScopePage.scopes()).singleElement().satisfies(scope -> { + assertThat(scope.scope()).isEqualTo("api:read"); + assertThat(scope.authorizedTargets()).containsExactly(instanceArn); + }); + assertThat(firstScopePage.nextToken()).isNotBlank(); + + var secondScopePage = sso.listApplicationAccessScopes(request -> request + .applicationArn(created.applicationArn()) + .maxResults(1) + .nextToken(firstScopePage.nextToken())); + assertThat(secondScopePage.scopes()).singleElement().satisfies(scope -> + assertThat(scope.scope()).isEqualTo("api:write")); + assertThat(secondScopePage.nextToken()).isNull(); + assertThatThrownBy(() -> sso.listApplicationAccessScopes(request -> request + .applicationArn(created.applicationArn()) + .maxResults(11))) + .isInstanceOf(ValidationException.class); } } diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index c923c0c74b..a7b16bba85 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -36,6 +36,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `PutApplicationAccessScope` | Creates or updates an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `GetApplicationAccessScope` | Returns an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | +| `ListApplicationAccessScopes` | Lists an application's access scopes and authorized IAM Identity Center targets with AWS-compatible pagination. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index b264937ec1..b8c2090095 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -55,6 +55,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplication" -> deleteApplication(request); case "PutApplicationAccessScope" -> putApplicationAccessScope(request); case "GetApplicationAccessScope" -> getApplicationAccessScope(request); + case "ListApplicationAccessScopes" -> listApplicationAccessScopes(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); @@ -268,6 +269,22 @@ private Response getApplicationAccessScope(JsonNode request) { return Response.ok(response).build(); } + private Response listApplicationAccessScopes(JsonNode request) { + var page = service.listApplicationAccessScopes(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode scopes = response.putArray("Scopes"); + page.items().forEach(accessScope -> { + ObjectNode scope = scopes.addObject(); + scope.put("Scope", accessScope.scope()); + ArrayNode targets = scope.putArray("AuthorizedTargets"); + accessScope.authorizedTargets().forEach(targets::add); + }); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response deleteApplicationAccessScope(JsonNode request) { service.deleteApplicationAccessScope(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index d58b7cee86..745ad78f7f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -600,6 +600,17 @@ public ApplicationAccessScope getApplicationAccessScope(JsonNode request) { .orElseThrow(() -> notFound("Application access scope not found: " + scope)); } + public PaginatedResult listApplicationAccessScopes(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + List matching = applicationAccessScopes.scan(key -> true).stream() + .filter(accessScope -> applicationArn.equals(accessScope.applicationArn())) + .sorted(Comparator.comparing(ApplicationAccessScope::scope)) + .toList(); + return Pagination.paginate(matching, ApplicationAccessScope::scope, + optionalMaxResults(request), text(request, "NextToken"), 10, 10, "ValidationException"); + } + public synchronized void deleteApplicationAccessScope(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 11b48271f0..70c419f1d1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -344,6 +344,58 @@ void putApplicationAccessScopeReturnsEmptyAwsResponse() { .body("AuthorizedTargets[0]", equalTo("arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e")); } + @Test + void listApplicationAccessScopesReturnsAwsScopeDetailsWithPagination() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"List Scope Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + for (String scope : java.util.List.of("api:read", "api:write")) { + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationAccessScope") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"Scope\":\"" + scope + "\"," + + "\"AuthorizedTargets\":[\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"]}") + .when().post("/") + .then().statusCode(200); + } + + String nextToken = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationAccessScopes") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"MaxResults\":1}") + .when().post("/") + .then() + .statusCode(200) + .body("Scopes.size()", equalTo(1)) + .body("Scopes[0].Scope", equalTo("api:read")) + .body("Scopes[0].AuthorizedTargets[0]", + equalTo("arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e")) + .extract().path("NextToken"); + org.junit.jupiter.api.Assertions.assertNotNull(nextToken); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationAccessScopes") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"MaxResults\":1,\"NextToken\":\"" + + nextToken + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Scopes.size()", equalTo(1)) + .body("Scopes[0].Scope", equalTo("api:write")); + } + @Test void listApplicationProvidersReturnsCustomOauthProvider() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 0070945597..49551798a0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -423,6 +423,49 @@ void putApplicationAccessScopeCreatesAndUpdatesAuthorizedTargets() { assertError("ValidationException", () -> service.putApplicationAccessScope(invalid)); } + @Test + void listApplicationAccessScopesPaginatesScopesForTheRequestedApplication() { + SsoApplication application = createApplication("List Scope App", "list-scope-app-token"); + SsoApplication otherApplication = createApplication("Other Scope App", "other-scope-app-token"); + for (String scope : java.util.List.of("api:read", "api:write")) { + ObjectNode put = mapper.createObjectNode(); + put.put("ApplicationArn", application.applicationArn()); + put.put("Scope", scope); + put.putArray("AuthorizedTargets").add(service.getInstanceArn()); + service.putApplicationAccessScope(put); + } + ObjectNode other = mapper.createObjectNode(); + other.put("ApplicationArn", otherApplication.applicationArn()); + other.put("Scope", "other:read"); + other.putArray("AuthorizedTargets").add(service.getInstanceArn()); + service.putApplicationAccessScope(other); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("MaxResults", 1); + var first = service.listApplicationAccessScopes(request); + assertEquals(1, first.items().size()); + assertEquals("api:read", first.items().get(0).scope()); + assertEquals(java.util.List.of(service.getInstanceArn()), first.items().get(0).authorizedTargets()); + assertNotNull(first.nextToken()); + + request.put("NextToken", first.nextToken()); + var second = service.listApplicationAccessScopes(request); + assertEquals(1, second.items().size()); + assertEquals("api:write", second.items().get(0).scope()); + assertNull(second.nextToken()); + + ObjectNode invalidMaxResults = mapper.createObjectNode(); + invalidMaxResults.put("ApplicationArn", application.applicationArn()); + invalidMaxResults.put("MaxResults", 11); + assertError("ValidationException", () -> service.listApplicationAccessScopes(invalidMaxResults)); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationArn", + "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.listApplicationAccessScopes(missing)); + } + @Test void deleteApplicationAccessScopeDeletesStoredScopeAndValidatesRequest() { SsoApplication application = createApplication("Scope App", "scope-app-token"); From 48243e381b1ac6882359ad5a21f646e3c7cdfa9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:01:26 -0400 Subject: [PATCH 64/99] feat(ssoadmin): get application assignment configuration --- .../test/SsoAdminAccountAssignmentTest.java | 4 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 12 ++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 24 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 19 +++++++++++++++ 6 files changed, 66 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index b8e094a552..22b8c6b68e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -170,6 +170,10 @@ void createApplicationUsesAwsSdk() { assertThat(applications.applications()).anySatisfy(application -> assertThat(application.applicationArn()).isEqualTo(created.applicationArn())); + var assignmentConfiguration = sso.getApplicationAssignmentConfiguration(request -> request + .applicationArn(created.applicationArn())); + assertThat(assignmentConfiguration.assignmentRequired()).isTrue(); + var putScope = sso.putApplicationAccessScope(request -> request .applicationArn(created.applicationArn()) .scope("api:read") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index a7b16bba85..62b97c56cd 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -34,6 +34,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAssignments` | Lists direct user and group assignments for an IAM Identity Center application with AWS-compatible pagination. | | `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | +| `GetApplicationAssignmentConfiguration` | Returns whether explicit assignments are required for an IAM Identity Center application, defaulting to the AWS value of true. | | `PutApplicationAccessScope` | Creates or updates an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `GetApplicationAccessScope` | Returns an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `ListApplicationAccessScopes` | Lists an application's access scopes and authorized IAM Identity Center targets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index b8c2090095..cec2ebf0f0 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -53,6 +53,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAssignments" -> listApplicationAssignments(request); case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); + case "GetApplicationAssignmentConfiguration" -> getApplicationAssignmentConfiguration(request); case "PutApplicationAccessScope" -> putApplicationAccessScope(request); case "GetApplicationAccessScope" -> getApplicationAccessScope(request); case "ListApplicationAccessScopes" -> listApplicationAccessScopes(request); @@ -255,6 +256,11 @@ private Response deleteApplication(JsonNode request) { return Response.ok().build(); } + private Response getApplicationAssignmentConfiguration(JsonNode request) { + return Response.ok(mapper.createObjectNode() + .put("AssignmentRequired", service.getApplicationAssignmentConfiguration(request))).build(); + } + private Response putApplicationAccessScope(JsonNode request) { service.putApplicationAccessScope(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 745ad78f7f..fd68ed1949 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -97,6 +97,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationClientTokens; private final StorageBackend applicationAssignments; private final StorageBackend applicationAccessScopes; + private final StorageBackend applicationAssignmentConfigurations; private final StorageBackend applicationAuthenticationMethods; private final StorageBackend applicationGrants; private final StorageBackend instances; @@ -125,6 +126,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-access-scopes.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-assignment-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-authentication-methods.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-grants.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), @@ -150,6 +152,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend applicationClientTokens, StorageBackend applicationAssignments, StorageBackend applicationAccessScopes, + StorageBackend applicationAssignmentConfigurations, StorageBackend applicationAuthenticationMethods, StorageBackend applicationGrants, StorageBackend instances, @@ -173,6 +176,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.applicationClientTokens = applicationClientTokens; this.applicationAssignments = applicationAssignments; this.applicationAccessScopes = applicationAccessScopes; + this.applicationAssignmentConfigurations = applicationAssignmentConfigurations; this.applicationAuthenticationMethods = applicationAuthenticationMethods; this.applicationGrants = applicationGrants; this.instances = instances; @@ -549,6 +553,7 @@ public synchronized void deleteApplication(String applicationArn) { applicationAccessScopes.delete(key); } } + applicationAssignmentConfigurations.delete(applicationArn); for (String key : new java.util.ArrayList<>(applicationAuthenticationMethods.keys())) { ApplicationAuthenticationMethod method = applicationAuthenticationMethods.get(key).orElse(null); if (method != null && applicationArn.equals(method.applicationArn())) { @@ -563,6 +568,12 @@ public synchronized void deleteApplication(String applicationArn) { } } + public boolean getApplicationAssignmentConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + return applicationAssignmentConfigurations.get(applicationArn).orElse(true); + } + public synchronized ApplicationAccessScope putApplicationAccessScope(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); @@ -1772,6 +1783,7 @@ public void clear() { applicationClientTokens.clear(); applicationAssignments.clear(); applicationAccessScopes.clear(); + applicationAssignmentConfigurations.clear(); applicationAuthenticationMethods.clear(); applicationGrants.clear(); instances.clear(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 70c419f1d1..3bec64474f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -310,6 +310,30 @@ void describeApplicationProviderReturnsCustomOauthProvider() { .body("FederationProtocol", equalTo("OAUTH")); } + @Test + void getApplicationAssignmentConfigurationReturnsTheAwsDefault() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Assignment Config Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationAssignmentConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("AssignmentRequired", equalTo(true)); + } + @Test void putApplicationAccessScopeReturnsEmptyAwsResponse() { String applicationArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 49551798a0..8432859e3c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -64,6 +64,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), applicationAccessScopes, + new InMemoryStorage(), applicationAuthenticationMethods, applicationGrants, new InMemoryStorage(), @@ -401,6 +402,23 @@ void deleteApplicationRemovesApplicationAssignmentsAndIdempotencyReferences() { assertError("ResourceNotFoundException", () -> service.deleteApplication(application.applicationArn())); } + @Test + void getApplicationAssignmentConfigurationDefaultsToAssignmentsRequired() { + SsoApplication application = createApplication("Assignment Config App", "assignment-config-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + + assertTrue(service.getApplicationAssignmentConfiguration(request)); + + ObjectNode malformed = mapper.createObjectNode().put("ApplicationArn", "not-an-arn"); + assertError("ValidationException", () -> service.getApplicationAssignmentConfiguration(malformed)); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationArn", + "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.getApplicationAssignmentConfiguration(missing)); + } + @Test void putApplicationAccessScopeCreatesAndUpdatesAuthorizedTargets() { SsoApplication application = createApplication("Put Scope App", "put-scope-app-token"); @@ -1223,6 +1241,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 0eea72036ff405ee66b314aef05ccd27922347d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:03:42 -0400 Subject: [PATCH 65/99] feat(ssoadmin): put application assignment configuration --- .../test/SsoAdminAccountAssignmentTest.java | 6 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 10 +++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 30 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 23 ++++++++++++++ 6 files changed, 76 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 22b8c6b68e..5e5380afd2 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -173,6 +173,12 @@ void createApplicationUsesAwsSdk() { var assignmentConfiguration = sso.getApplicationAssignmentConfiguration(request -> request .applicationArn(created.applicationArn())); assertThat(assignmentConfiguration.assignmentRequired()).isTrue(); + var putAssignmentConfiguration = sso.putApplicationAssignmentConfiguration(request -> request + .applicationArn(created.applicationArn()) + .assignmentRequired(false)); + assertThat(putAssignmentConfiguration.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.getApplicationAssignmentConfiguration(request -> request + .applicationArn(created.applicationArn())).assignmentRequired()).isFalse(); var putScope = sso.putApplicationAccessScope(request -> request .applicationArn(created.applicationArn()) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 62b97c56cd..e268fe6632 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -35,6 +35,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAssignmentsForPrincipal` | Lists effective application access for a user or group, including group-derived user access, with instance-aware filtering and pagination. | | `DeleteApplication` | Deletes the IAM Identity Center application association and its local assignment state. | | `GetApplicationAssignmentConfiguration` | Returns whether explicit assignments are required for an IAM Identity Center application, defaulting to the AWS value of true. | +| `PutApplicationAssignmentConfiguration` | Configures whether explicit assignments are required for an IAM Identity Center application. | | `PutApplicationAccessScope` | Creates or updates an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `GetApplicationAccessScope` | Returns an application access scope and its authorized IAM Identity Center targets with AWS-compatible validation. | | `ListApplicationAccessScopes` | Lists an application's access scopes and authorized IAM Identity Center targets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index cec2ebf0f0..efb9e8cd4d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -54,6 +54,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); case "GetApplicationAssignmentConfiguration" -> getApplicationAssignmentConfiguration(request); + case "PutApplicationAssignmentConfiguration" -> putApplicationAssignmentConfiguration(request); case "PutApplicationAccessScope" -> putApplicationAccessScope(request); case "GetApplicationAccessScope" -> getApplicationAccessScope(request); case "ListApplicationAccessScopes" -> listApplicationAccessScopes(request); @@ -261,6 +262,11 @@ private Response getApplicationAssignmentConfiguration(JsonNode request) { .put("AssignmentRequired", service.getApplicationAssignmentConfiguration(request))).build(); } + private Response putApplicationAssignmentConfiguration(JsonNode request) { + service.putApplicationAssignmentConfiguration(request); + return Response.ok().build(); + } + private Response putApplicationAccessScope(JsonNode request) { service.putApplicationAccessScope(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index fd68ed1949..ae964ef99f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -574,6 +574,16 @@ public boolean getApplicationAssignmentConfiguration(JsonNode request) { return applicationAssignmentConfigurations.get(applicationArn).orElse(true); } + public synchronized void putApplicationAssignmentConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + JsonNode assignmentRequired = request == null ? null : request.get("AssignmentRequired"); + if (assignmentRequired == null || assignmentRequired.isNull() || !assignmentRequired.isBoolean()) { + throw validation("AssignmentRequired must be a boolean."); + } + applicationAssignmentConfigurations.put(applicationArn, assignmentRequired.booleanValue()); + } + public synchronized ApplicationAccessScope putApplicationAccessScope(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 3bec64474f..5a88aade3a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -334,6 +334,36 @@ void getApplicationAssignmentConfigurationReturnsTheAwsDefault() { .body("AssignmentRequired", equalTo(true)); } + @Test + void putApplicationAssignmentConfigurationUpdatesTheAwsSetting() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Put Assignment Config Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationAssignmentConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"AssignmentRequired\":false}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationAssignmentConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200).body("AssignmentRequired", equalTo(false)); + } + @Test void putApplicationAccessScopeReturnsEmptyAwsResponse() { String applicationArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 8432859e3c..85886756fe 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -419,6 +419,29 @@ void getApplicationAssignmentConfigurationDefaultsToAssignmentsRequired() { assertError("ResourceNotFoundException", () -> service.getApplicationAssignmentConfiguration(missing)); } + @Test + void putApplicationAssignmentConfigurationPersistsExplicitAccessRequirement() { + SsoApplication application = createApplication("Put Assignment Config App", "put-assignment-config-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("AssignmentRequired", false); + + service.putApplicationAssignmentConfiguration(request); + assertFalse(service.getApplicationAssignmentConfiguration(request)); + + request.put("AssignmentRequired", true); + service.putApplicationAssignmentConfiguration(request); + assertTrue(service.getApplicationAssignmentConfiguration(request)); + + ObjectNode missingValue = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + assertError("ValidationException", () -> service.putApplicationAssignmentConfiguration(missingValue)); + + ObjectNode wrongType = mapper.createObjectNode(); + wrongType.put("ApplicationArn", application.applicationArn()); + wrongType.put("AssignmentRequired", "false"); + assertError("ValidationException", () -> service.putApplicationAssignmentConfiguration(wrongType)); + } + @Test void putApplicationAccessScopeCreatesAndUpdatesAuthorizedTargets() { SsoApplication application = createApplication("Put Scope App", "put-scope-app-token"); From e6116b57966df52d7127e37e90f83d551c820dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:06:13 -0400 Subject: [PATCH 66/99] feat(ssoadmin): get application authentication methods --- .../test/SsoAdminAccountAssignmentTest.java | 9 ++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 8 +++++++ .../services/ssoadmin/SsoAdminService.java | 13 +++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 23 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 22 ++++++++++++++++++ 6 files changed, 76 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 5e5380afd2..02a86f4a6e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -319,6 +319,15 @@ void deleteApplicationAuthenticationMethodUsesAwsSdk() { .name("SDK Delete Authentication Method")) .applicationArn(); + assertThatThrownBy(() -> sso.getApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> sso.getApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("SAML"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ValidationException.class); + assertThatThrownBy(() -> sso.deleteApplicationAuthenticationMethod(request -> request .applicationArn(applicationArn) .authenticationMethodType("IAM"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index e268fe6632..3e59259c66 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -41,6 +41,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAccessScopes` | Lists an application's access scopes and authorized IAM Identity Center targets with AWS-compatible pagination. | | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | +| `GetApplicationAuthenticationMethod` | Returns the configured IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index efb9e8cd4d..3ba3af7b1d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -60,6 +60,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAccessScopes" -> listApplicationAccessScopes(request); case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); + case "GetApplicationAuthenticationMethod" -> getApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); @@ -368,6 +369,13 @@ private Response deleteApplicationAssignment(JsonNode request) { return Response.ok().build(); } + private Response getApplicationAuthenticationMethod(JsonNode request) { + var method = service.getApplicationAuthenticationMethod(request); + ObjectNode response = mapper.createObjectNode(); + response.set("AuthenticationMethod", method.authenticationMethod()); + return Response.ok(response).build(); + } + private Response deleteApplicationAuthenticationMethod(JsonNode request) { service.deleteApplicationAuthenticationMethod(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ae964ef99f..79caa0a229 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -762,6 +762,19 @@ public synchronized void deleteApplicationAssignment(JsonNode request) { applicationAssignments.delete(key); } + public ApplicationAuthenticationMethod getApplicationAuthenticationMethod(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String authenticationMethodType = required(request, "AuthenticationMethodType"); + if (!"IAM".equals(authenticationMethodType)) { + throw validation("AuthenticationMethodType must be IAM."); + } + return applicationAuthenticationMethods.get( + applicationAuthenticationMethodKey(applicationArn, authenticationMethodType)) + .orElseThrow(() -> notFound( + "Application authentication method not found: " + authenticationMethodType)); + } + public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 5a88aade3a..1e86471e25 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -502,6 +502,29 @@ void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); } + @Test + void getApplicationAuthenticationMethodReturnsNotFoundWhenIamMethodIsAbsent() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Get Authentication Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationAuthenticationMethod") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"AuthenticationMethodType\":\"IAM\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", equalTo("ResourceNotFoundException")); + } + @Test void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 85886756fe..58c44e7689 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -527,6 +527,28 @@ void deleteApplicationAccessScopeDeletesStoredScopeAndValidatesRequest() { assertError("ValidationException", () -> service.deleteApplicationAccessScope(invalid)); } + @Test + void getApplicationAuthenticationMethodReturnsStoredIamMethod() { + SsoApplication application = createApplication("Get Authentication App", "get-authentication-app-token"); + String key = SsoAdminService.applicationAuthenticationMethodKey(application.applicationArn(), "IAM"); + ObjectNode method = mapper.createObjectNode(); + method.putObject("Iam").putObject("ActorPolicy").put("Version", "2012-10-17"); + applicationAuthenticationMethods.put(key, new ApplicationAuthenticationMethod( + application.applicationArn(), "IAM", method)); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("AuthenticationMethodType", "IAM"); + assertEquals(method, service.getApplicationAuthenticationMethod(request).authenticationMethod()); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("AuthenticationMethodType", "SAML"); + assertError("ValidationException", () -> service.getApplicationAuthenticationMethod(invalidType)); + + applicationAuthenticationMethods.delete(key); + assertError("ResourceNotFoundException", () -> service.getApplicationAuthenticationMethod(request)); + } + @Test void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { SsoApplication application = createApplication("Authentication App", "authentication-app-token"); From 06a845bb1716cd6f14dc9b3f89bb00c54c30abfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:08:16 -0400 Subject: [PATCH 67/99] feat(ssoadmin): put application authentication methods --- .../test/SsoAdminAccountAssignmentTest.java | 19 ++++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 21 +++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 35 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 27 ++++++++++++++ 6 files changed, 109 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 02a86f4a6e..872f17a7b5 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -319,6 +319,25 @@ void deleteApplicationAuthenticationMethodUsesAwsSdk() { .name("SDK Delete Authentication Method")) .applicationArn(); + var putMethod = sso.putApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM") + .authenticationMethod(method -> method.iam(iam -> iam.actorPolicy( + software.amazon.awssdk.core.document.Document.mapBuilder() + .putString("Version", "2012-10-17") + .putDocument("Statement", software.amazon.awssdk.core.document.Document.fromList( + java.util.List.of())) + .build())))); + assertThat(putMethod.sdkHttpResponse().isSuccessful()).isTrue(); + var fetchedMethod = sso.getApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM")); + assertThat(fetchedMethod.authenticationMethod().iam().actorPolicy().asMap().get("Version").asString()) + .isEqualTo("2012-10-17"); + + sso.deleteApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM")); assertThatThrownBy(() -> sso.getApplicationAuthenticationMethod(request -> request .applicationArn(applicationArn) .authenticationMethodType("IAM"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 3e59259c66..c7122c64fd 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -42,6 +42,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `GetApplicationAuthenticationMethod` | Returns the configured IAM authentication method and actor policy for an IAM Identity Center application. | +| `PutApplicationAuthenticationMethod` | Creates or updates the IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 3ba3af7b1d..616fc2aff7 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -61,6 +61,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "GetApplicationAuthenticationMethod" -> getApplicationAuthenticationMethod(request); + case "PutApplicationAuthenticationMethod" -> putApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); @@ -376,6 +377,11 @@ private Response getApplicationAuthenticationMethod(JsonNode request) { return Response.ok(response).build(); } + private Response putApplicationAuthenticationMethod(JsonNode request) { + service.putApplicationAuthenticationMethod(request); + return Response.ok().build(); + } + private Response deleteApplicationAuthenticationMethod(JsonNode request) { service.deleteApplicationAuthenticationMethod(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 79caa0a229..11a2071ae4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -775,6 +775,27 @@ public ApplicationAuthenticationMethod getApplicationAuthenticationMethod(JsonNo "Application authentication method not found: " + authenticationMethodType)); } + public synchronized void putApplicationAuthenticationMethod(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String authenticationMethodType = required(request, "AuthenticationMethodType"); + if (!"IAM".equals(authenticationMethodType)) { + throw validation("AuthenticationMethodType must be IAM."); + } + JsonNode authenticationMethod = request == null ? null : request.get("AuthenticationMethod"); + if (authenticationMethod == null || !authenticationMethod.isObject() + || authenticationMethod.size() != 1 || !authenticationMethod.has("Iam")) { + throw validation("AuthenticationMethod must contain exactly one Iam member."); + } + JsonNode iam = authenticationMethod.get("Iam"); + if (iam == null || !iam.isObject() || !iam.has("ActorPolicy") || iam.get("ActorPolicy").isNull()) { + throw validation("AuthenticationMethod.Iam.ActorPolicy is required."); + } + String key = applicationAuthenticationMethodKey(applicationArn, authenticationMethodType); + applicationAuthenticationMethods.put(key, new ApplicationAuthenticationMethod( + applicationArn, authenticationMethodType, authenticationMethod.deepCopy())); + } + public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 1e86471e25..2446501600 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -502,6 +502,41 @@ void deleteApplicationAssignmentReturnsEmptyResponseAndRevokesAssignment() { .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); } + @Test + void putAndGetApplicationAuthenticationMethodRoundTripsActorPolicy() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Put Authentication Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + String body = "{\"ApplicationArn\":\"" + applicationArn + "\"," + + "\"AuthenticationMethodType\":\"IAM\"," + + "\"AuthenticationMethod\":{\"Iam\":{\"ActorPolicy\":{\"Version\":\"2012-10-17\"," + + "\"Statement\":[]}}}}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationAuthenticationMethod") + .body(body) + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationAuthenticationMethod") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"AuthenticationMethodType\":\"IAM\"}") + .when().post("/") + .then().statusCode(200) + .body("AuthenticationMethod.Iam.ActorPolicy.Version", equalTo("2012-10-17")); + } + @Test void getApplicationAuthenticationMethodReturnsNotFoundWhenIamMethodIsAbsent() { String applicationArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 58c44e7689..c9cfe9caaa 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -549,6 +549,33 @@ void getApplicationAuthenticationMethodReturnsStoredIamMethod() { assertError("ResourceNotFoundException", () -> service.getApplicationAuthenticationMethod(request)); } + @Test + void putApplicationAuthenticationMethodCreatesAndUpdatesIamActorPolicy() { + SsoApplication application = createApplication("Put Authentication App", "put-authentication-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("AuthenticationMethodType", "IAM"); + ObjectNode method = request.putObject("AuthenticationMethod"); + method.putObject("Iam").putObject("ActorPolicy").put("Version", "2012-10-17"); + + service.putApplicationAuthenticationMethod(request); + var stored = service.getApplicationAuthenticationMethod(request); + assertEquals(method, stored.authenticationMethod()); + + method.withObject("Iam").withObject("ActorPolicy").put("Id", "updated"); + service.putApplicationAuthenticationMethod(request); + assertEquals("updated", service.getApplicationAuthenticationMethod(request) + .authenticationMethod().path("Iam").path("ActorPolicy").path("Id").asText()); + + ObjectNode missingActorPolicy = request.deepCopy(); + ((ObjectNode) missingActorPolicy.path("AuthenticationMethod").path("Iam")).remove("ActorPolicy"); + assertError("ValidationException", () -> service.putApplicationAuthenticationMethod(missingActorPolicy)); + + ObjectNode wrongUnion = request.deepCopy(); + ((ObjectNode) wrongUnion.path("AuthenticationMethod")).set("Other", mapper.createObjectNode()); + assertError("ValidationException", () -> service.putApplicationAuthenticationMethod(wrongUnion)); + } + @Test void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { SsoApplication application = createApplication("Authentication App", "authentication-app-token"); From 7763c5aad16d2effd848e792cf0581d10afc50cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:09:47 -0400 Subject: [PATCH 68/99] feat(ssoadmin): list application authentication methods --- .../test/SsoAdminAccountAssignmentTest.java | 7 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 16 +++++++++++++ .../services/ssoadmin/SsoAdminService.java | 11 +++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 11 +++++++++ .../ssoadmin/SsoAdminServiceTest.java | 24 +++++++++++++++++++ 6 files changed, 70 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 872f17a7b5..56f9fb9a13 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -334,6 +334,13 @@ void deleteApplicationAuthenticationMethodUsesAwsSdk() { .authenticationMethodType("IAM")); assertThat(fetchedMethod.authenticationMethod().iam().actorPolicy().asMap().get("Version").asString()) .isEqualTo("2012-10-17"); + var listedMethods = sso.listApplicationAuthenticationMethods(request -> request + .applicationArn(applicationArn)); + assertThat(listedMethods.authenticationMethods()).singleElement().satisfies(method -> { + assertThat(method.authenticationMethodTypeAsString()).isEqualTo("IAM"); + assertThat(method.authenticationMethod().iam().actorPolicy().asMap().get("Version").asString()) + .isEqualTo("2012-10-17"); + }); sso.deleteApplicationAuthenticationMethod(request -> request .applicationArn(applicationArn) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index c7122c64fd..4ca965c10c 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -42,6 +42,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplicationAccessScope` | Deletes an application access scope after validating its AWS scope name and application ARN. | | `DeleteApplicationAssignment` | Revokes a direct user or group assignment from an IAM Identity Center application. | | `GetApplicationAuthenticationMethod` | Returns the configured IAM authentication method and actor policy for an IAM Identity Center application. | +| `ListApplicationAuthenticationMethods` | Lists configured IAM authentication methods and actor policies for an IAM Identity Center application. | | `PutApplicationAuthenticationMethod` | Creates or updates the IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 616fc2aff7..027ebcd4cd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -61,6 +61,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplicationAccessScope" -> deleteApplicationAccessScope(request); case "DeleteApplicationAssignment" -> deleteApplicationAssignment(request); case "GetApplicationAuthenticationMethod" -> getApplicationAuthenticationMethod(request); + case "ListApplicationAuthenticationMethods" -> listApplicationAuthenticationMethods(request); case "PutApplicationAuthenticationMethod" -> putApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); @@ -377,6 +378,21 @@ private Response getApplicationAuthenticationMethod(JsonNode request) { return Response.ok(response).build(); } + private Response listApplicationAuthenticationMethods(JsonNode request) { + var page = service.listApplicationAuthenticationMethods(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode methods = response.putArray("AuthenticationMethods"); + page.items().forEach(method -> { + ObjectNode item = methods.addObject(); + item.put("AuthenticationMethodType", method.authenticationMethodType()); + item.set("AuthenticationMethod", method.authenticationMethod()); + }); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response putApplicationAuthenticationMethod(JsonNode request) { service.putApplicationAuthenticationMethod(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 11a2071ae4..f83d6737a2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -775,6 +775,17 @@ public ApplicationAuthenticationMethod getApplicationAuthenticationMethod(JsonNo "Application authentication method not found: " + authenticationMethodType)); } + public PaginatedResult listApplicationAuthenticationMethods(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + List matching = applicationAuthenticationMethods.scan(key -> true).stream() + .filter(method -> applicationArn.equals(method.applicationArn())) + .sorted(Comparator.comparing(ApplicationAuthenticationMethod::authenticationMethodType)) + .toList(); + return Pagination.paginate(matching, ApplicationAuthenticationMethod::authenticationMethodType, + null, text(request, "NextToken"), 100, 100, "ValidationException"); + } + public synchronized void putApplicationAuthenticationMethod(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 2446501600..c0b12a674a 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -535,6 +535,17 @@ void putAndGetApplicationAuthenticationMethodRoundTripsActorPolicy() { .when().post("/") .then().statusCode(200) .body("AuthenticationMethod.Iam.ActorPolicy.Version", equalTo("2012-10-17")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationAuthenticationMethods") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("AuthenticationMethods.size()", equalTo(1)) + .body("AuthenticationMethods[0].AuthenticationMethodType", equalTo("IAM")) + .body("AuthenticationMethods[0].AuthenticationMethod.Iam.ActorPolicy.Version", equalTo("2012-10-17")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index c9cfe9caaa..be50ba98a6 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -549,6 +549,30 @@ void getApplicationAuthenticationMethodReturnsStoredIamMethod() { assertError("ResourceNotFoundException", () -> service.getApplicationAuthenticationMethod(request)); } + @Test + void listApplicationAuthenticationMethodsReturnsOnlyMethodsForRequestedApplication() { + SsoApplication application = createApplication("List Authentication App", "list-authentication-app-token"); + SsoApplication otherApplication = createApplication("Other Authentication App", "other-authentication-app-token"); + ObjectNode method = mapper.createObjectNode(); + method.putObject("Iam").putObject("ActorPolicy").put("Version", "2012-10-17"); + applicationAuthenticationMethods.put( + SsoAdminService.applicationAuthenticationMethodKey(application.applicationArn(), "IAM"), + new ApplicationAuthenticationMethod(application.applicationArn(), "IAM", method)); + applicationAuthenticationMethods.put( + SsoAdminService.applicationAuthenticationMethodKey(otherApplication.applicationArn(), "IAM"), + new ApplicationAuthenticationMethod(otherApplication.applicationArn(), "IAM", method.deepCopy())); + + ObjectNode request = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + var result = service.listApplicationAuthenticationMethods(request); + assertEquals(1, result.items().size()); + assertEquals("IAM", result.items().get(0).authenticationMethodType()); + assertEquals(method, result.items().get(0).authenticationMethod()); + assertNull(result.nextToken()); + + ObjectNode badToken = request.deepCopy().put("NextToken", "not-a-token"); + assertError("ValidationException", () -> service.listApplicationAuthenticationMethods(badToken)); + } + @Test void putApplicationAuthenticationMethodCreatesAndUpdatesIamActorPolicy() { SsoApplication application = createApplication("Put Authentication App", "put-authentication-app-token"); From 10a974c1cce2e12ce5077de61a3b83cfe817bdb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:12:12 -0400 Subject: [PATCH 69/99] feat(ssoadmin): get application grants --- .../test/SsoAdminAccountAssignmentTest.java | 9 ++++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 11 +++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 23 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 22 ++++++++++++++++++ 6 files changed, 72 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 56f9fb9a13..6ad2e7645e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -378,6 +378,15 @@ void deleteApplicationGrantUsesAwsSdk() { .name("SDK Delete Grant")) .applicationArn(); + assertThatThrownBy(() -> sso.getApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> sso.getApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("client_credentials"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ValidationException.class); + assertThatThrownBy(() -> sso.deleteApplicationGrant(request -> request .applicationArn(applicationArn) .grantType("authorization_code"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 4ca965c10c..813cdae868 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -45,6 +45,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListApplicationAuthenticationMethods` | Lists configured IAM authentication methods and actor policies for an IAM Identity Center application. | | `PutApplicationAuthenticationMethod` | Creates or updates the IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | +| `GetApplicationGrant` | Returns the configured OAuth 2.0 grant union for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 027ebcd4cd..8d360c65e2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -64,6 +64,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplicationAuthenticationMethods" -> listApplicationAuthenticationMethods(request); case "PutApplicationAuthenticationMethod" -> putApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); + case "GetApplicationGrant" -> getApplicationGrant(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); @@ -403,6 +404,11 @@ private Response deleteApplicationAuthenticationMethod(JsonNode request) { return Response.ok().build(); } + private Response getApplicationGrant(JsonNode request) { + var grant = service.getApplicationGrant(request); + return Response.ok(mapper.createObjectNode().set("Grant", grant.grant())).build(); + } + private Response deleteApplicationGrant(JsonNode request) { service.deleteApplicationGrant(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index f83d6737a2..aecf03177a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -821,6 +821,17 @@ public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) applicationAuthenticationMethods.delete(key); } + public ApplicationGrant getApplicationGrant(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String grantType = required(request, "GrantType"); + if (!APPLICATION_GRANT_TYPES.contains(grantType)) { + throw validation("GrantType is invalid."); + } + return applicationGrants.get(applicationGrantKey(applicationArn, grantType)) + .orElseThrow(() -> notFound("Application grant not found: " + grantType)); + } + public synchronized void deleteApplicationGrant(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index c0b12a674a..277639f400 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -600,6 +600,29 @@ void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); } + @Test + void getApplicationGrantReturnsNotFoundWhenGrantIsAbsent() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Get Grant Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationGrant") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"GrantType\":\"authorization_code\"}") + .when().post("/") + .then().statusCode(400) + .body("__type", equalTo("ResourceNotFoundException")); + } + @Test void deleteApplicationGrantValidatesGrantTypeAndMissingGrant() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index be50ba98a6..b124546826 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -621,6 +621,28 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void getApplicationGrantReturnsStoredGrantAndValidatesType() { + SsoApplication application = createApplication("Get Grant App", "get-grant-app-token"); + String grantType = "authorization_code"; + String key = SsoAdminService.applicationGrantKey(application.applicationArn(), grantType); + ObjectNode grant = mapper.createObjectNode(); + grant.putObject("AuthorizationCode").putArray("RedirectUris").add("https://example.com/callback"); + applicationGrants.put(key, new ApplicationGrant(application.applicationArn(), grantType, grant)); + + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("GrantType", grantType); + assertEquals(grant, service.getApplicationGrant(request).grant()); + + ObjectNode invalidType = request.deepCopy(); + invalidType.put("GrantType", "client_credentials"); + assertError("ValidationException", () -> service.getApplicationGrant(invalidType)); + + applicationGrants.delete(key); + assertError("ResourceNotFoundException", () -> service.getApplicationGrant(request)); + } + @Test void deleteApplicationGrantDeletesConfiguredGrantAndValidatesGrantType() { SsoApplication application = createApplication("Grant App", "grant-app-token"); From bc283b2fce755282cf8706250d6e1d29189b25b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:14:13 -0400 Subject: [PATCH 70/99] feat(ssoadmin): put application grants --- .../test/SsoAdminAccountAssignmentTest.java | 14 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 70 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 33 +++++++++ .../ssoadmin/SsoAdminServiceTest.java | 47 +++++++++++++ 6 files changed, 171 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 6ad2e7645e..22c9c01f8d 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -378,6 +378,20 @@ void deleteApplicationGrantUsesAwsSdk() { .name("SDK Delete Grant")) .applicationArn(); + var putGrant = sso.putApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code") + .grant(grant -> grant.authorizationCode(code -> code + .redirectUris("https://example.com/callback")))); + assertThat(putGrant.sdkHttpResponse().isSuccessful()).isTrue(); + var fetchedGrant = sso.getApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code")); + assertThat(fetchedGrant.grant().authorizationCode().redirectUris()) + .containsExactly("https://example.com/callback"); + sso.deleteApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code")); assertThatThrownBy(() -> sso.getApplicationGrant(request -> request .applicationArn(applicationArn) .grantType("authorization_code"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 813cdae868..730950e2b1 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -46,6 +46,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `PutApplicationAuthenticationMethod` | Creates or updates the IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `GetApplicationGrant` | Returns the configured OAuth 2.0 grant union for an IAM Identity Center application. | +| `PutApplicationGrant` | Creates or updates a supported OAuth 2.0 grant configuration for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | | `CreatePermissionSet` | Creates a permission set. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 8d360c65e2..6d088c416f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -65,6 +65,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "PutApplicationAuthenticationMethod" -> putApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "GetApplicationGrant" -> getApplicationGrant(request); + case "PutApplicationGrant" -> putApplicationGrant(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); case "CreatePermissionSet" -> createPermissionSet(request); @@ -409,6 +410,11 @@ private Response getApplicationGrant(JsonNode request) { return Response.ok(mapper.createObjectNode().set("Grant", grant.grant())).build(); } + private Response putApplicationGrant(JsonNode request) { + service.putApplicationGrant(request); + return Response.ok().build(); + } + private Response deleteApplicationGrant(JsonNode request) { service.deleteApplicationGrant(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index aecf03177a..6ff6a6226c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -832,6 +832,76 @@ public ApplicationGrant getApplicationGrant(JsonNode request) { .orElseThrow(() -> notFound("Application grant not found: " + grantType)); } + public synchronized void putApplicationGrant(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + String grantType = required(request, "GrantType"); + if (!APPLICATION_GRANT_TYPES.contains(grantType)) { + throw validation("GrantType is invalid."); + } + JsonNode grant = request == null ? null : request.get("Grant"); + if (grant == null || !grant.isObject() || grant.size() != 1) { + throw validation("Grant must contain exactly one grant configuration."); + } + String expectedMember = switch (grantType) { + case "authorization_code" -> "AuthorizationCode"; + case "refresh_token" -> "RefreshToken"; + case "urn:ietf:params:oauth:grant-type:jwt-bearer" -> "JwtBearer"; + case "urn:ietf:params:oauth:grant-type:token-exchange" -> "TokenExchange"; + default -> throw validation("GrantType is invalid."); + }; + if (!grant.has(expectedMember) || !grant.get(expectedMember).isObject()) { + throw validation("Grant configuration must match GrantType."); + } + validateApplicationGrant(expectedMember, grant.get(expectedMember)); + applicationGrants.put(applicationGrantKey(applicationArn, grantType), + new ApplicationGrant(applicationArn, grantType, grant.deepCopy())); + } + + private static void validateApplicationGrant(String member, JsonNode configuration) { + if ("AuthorizationCode".equals(member)) { + JsonNode redirectUris = configuration.get("RedirectUris"); + if (redirectUris == null || !redirectUris.isArray() || redirectUris.size() < 1 || redirectUris.size() > 10) { + throw validation("AuthorizationCode.RedirectUris must contain between 1 and 10 values."); + } + for (JsonNode uri : redirectUris) { + if (!uri.isTextual()) { + throw validation("AuthorizationCode.RedirectUris values must be strings."); + } + } + return; + } + if ("JwtBearer".equals(member)) { + JsonNode issuers = configuration.get("AuthorizedTokenIssuers"); + if (issuers == null || !issuers.isArray() || issuers.size() < 1 || issuers.size() > 10) { + throw validation("JwtBearer.AuthorizedTokenIssuers must contain between 1 and 10 values."); + } + for (JsonNode issuer : issuers) { + if (!issuer.isObject()) { + throw validation("AuthorizedTokenIssuers values must be objects."); + } + JsonNode issuerArn = issuer.get("TrustedTokenIssuerArn"); + if (issuerArn != null && !issuerArn.isNull()) { + if (!issuerArn.isTextual()) { + throw validation("TrustedTokenIssuerArn must be a string."); + } + validateTrustedTokenIssuerArn(issuerArn.textValue()); + } + JsonNode audiences = issuer.get("AuthorizedAudiences"); + if (audiences != null && !audiences.isNull()) { + if (!audiences.isArray() || audiences.size() < 1 || audiences.size() > 10) { + throw validation("AuthorizedAudiences must contain between 1 and 10 values."); + } + for (JsonNode audience : audiences) { + if (!audience.isTextual() || audience.textValue().isEmpty() || audience.textValue().length() > 512) { + throw validation("AuthorizedAudiences values must be strings between 1 and 512 characters."); + } + } + } + } + } + } + public synchronized void deleteApplicationGrant(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 277639f400..15c60ef109 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -600,6 +600,39 @@ void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); } + @Test + void putAndGetApplicationGrantRoundTripsAuthorizationCodeGrant() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Put Grant Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationGrant") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"," + + "\"GrantType\":\"authorization_code\"," + + "\"Grant\":{\"AuthorizationCode\":{\"RedirectUris\":[\"https://example.com/callback\"]}}}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationGrant") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"GrantType\":\"authorization_code\"}") + .when().post("/") + .then().statusCode(200) + .body("Grant.AuthorizationCode.RedirectUris[0]", equalTo("https://example.com/callback")); + } + @Test void getApplicationGrantReturnsNotFoundWhenGrantIsAbsent() { String applicationArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index b124546826..4f4151623e 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -621,6 +621,53 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void putApplicationGrantValidatesUnionAndPersistsSupportedGrantTypes() { + SsoApplication application = createApplication("Put Grant App", "put-grant-app-token"); + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationArn", application.applicationArn()); + request.put("GrantType", "authorization_code"); + request.putObject("Grant").putObject("AuthorizationCode") + .putArray("RedirectUris").add("https://example.com/callback"); + + service.putApplicationGrant(request); + assertEquals("https://example.com/callback", service.getApplicationGrant(request) + .grant().path("AuthorizationCode").path("RedirectUris").get(0).asText()); + + ObjectNode refresh = mapper.createObjectNode(); + refresh.put("ApplicationArn", application.applicationArn()); + refresh.put("GrantType", "refresh_token"); + refresh.putObject("Grant").putObject("RefreshToken"); + service.putApplicationGrant(refresh); + assertTrue(service.getApplicationGrant(refresh).grant().has("RefreshToken")); + + ObjectNode tokenExchange = mapper.createObjectNode(); + tokenExchange.put("ApplicationArn", application.applicationArn()); + tokenExchange.put("GrantType", "urn:ietf:params:oauth:grant-type:token-exchange"); + tokenExchange.putObject("Grant").putObject("TokenExchange"); + service.putApplicationGrant(tokenExchange); + assertTrue(service.getApplicationGrant(tokenExchange).grant().has("TokenExchange")); + + ObjectNode jwt = mapper.createObjectNode(); + jwt.put("ApplicationArn", application.applicationArn()); + jwt.put("GrantType", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + ObjectNode issuer = jwt.putObject("Grant").putObject("JwtBearer") + .putArray("AuthorizedTokenIssuers").addObject(); + issuer.put("TrustedTokenIssuerArn", + "arn:aws:sso::123456789012:trustedTokenIssuer/ssoins-7223b02a5d9f7c8e/tti-11111111-2222-3333-4444-555555555555"); + issuer.putArray("AuthorizedAudiences").add("api://example"); + service.putApplicationGrant(jwt); + assertTrue(service.getApplicationGrant(jwt).grant().has("JwtBearer")); + + ObjectNode mismatched = request.deepCopy(); + mismatched.set("Grant", mapper.createObjectNode().set("RefreshToken", mapper.createObjectNode())); + assertError("ValidationException", () -> service.putApplicationGrant(mismatched)); + + ObjectNode missingRedirects = request.deepCopy(); + missingRedirects.set("Grant", mapper.createObjectNode().set("AuthorizationCode", mapper.createObjectNode())); + assertError("ValidationException", () -> service.putApplicationGrant(missingRedirects)); + } + @Test void getApplicationGrantReturnsStoredGrantAndValidatesType() { SsoApplication application = createApplication("Get Grant App", "get-grant-app-token"); From f72f9f54113ffeae2fbeba93b22eb6eb98d26d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:16:13 -0400 Subject: [PATCH 71/99] feat(ssoadmin): list application grants --- .../test/SsoAdminAccountAssignmentTest.java | 6 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 16 ++++++++++++ .../services/ssoadmin/SsoAdminService.java | 11 ++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 11 ++++++++ .../ssoadmin/SsoAdminServiceTest.java | 26 +++++++++++++++++++ 6 files changed, 71 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 22c9c01f8d..628e8ca2c4 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -389,6 +389,12 @@ void deleteApplicationGrantUsesAwsSdk() { .grantType("authorization_code")); assertThat(fetchedGrant.grant().authorizationCode().redirectUris()) .containsExactly("https://example.com/callback"); + var listedGrants = sso.listApplicationGrants(request -> request.applicationArn(applicationArn)); + assertThat(listedGrants.grants()).singleElement().satisfies(grant -> { + assertThat(grant.grantTypeAsString()).isEqualTo("authorization_code"); + assertThat(grant.grant().authorizationCode().redirectUris()) + .containsExactly("https://example.com/callback"); + }); sso.deleteApplicationGrant(request -> request .applicationArn(applicationArn) .grantType("authorization_code")); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 730950e2b1..c99205a95b 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -46,6 +46,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `PutApplicationAuthenticationMethod` | Creates or updates the IAM authentication method and actor policy for an IAM Identity Center application. | | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `GetApplicationGrant` | Returns the configured OAuth 2.0 grant union for an IAM Identity Center application. | +| `ListApplicationGrants` | Lists configured OAuth 2.0 grants and their union configuration for an IAM Identity Center application. | | `PutApplicationGrant` | Creates or updates a supported OAuth 2.0 grant configuration for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 6d088c416f..1f2e70acd4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -65,6 +65,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "PutApplicationAuthenticationMethod" -> putApplicationAuthenticationMethod(request); case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "GetApplicationGrant" -> getApplicationGrant(request); + case "ListApplicationGrants" -> listApplicationGrants(request); case "PutApplicationGrant" -> putApplicationGrant(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); @@ -410,6 +411,21 @@ private Response getApplicationGrant(JsonNode request) { return Response.ok(mapper.createObjectNode().set("Grant", grant.grant())).build(); } + private Response listApplicationGrants(JsonNode request) { + var page = service.listApplicationGrants(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode grants = response.putArray("Grants"); + page.items().forEach(grant -> { + ObjectNode item = grants.addObject(); + item.put("GrantType", grant.grantType()); + item.set("Grant", grant.grant()); + }); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response putApplicationGrant(JsonNode request) { service.putApplicationGrant(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6ff6a6226c..6615c7c016 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -832,6 +832,17 @@ public ApplicationGrant getApplicationGrant(JsonNode request) { .orElseThrow(() -> notFound("Application grant not found: " + grantType)); } + public PaginatedResult listApplicationGrants(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + List matching = applicationGrants.scan(key -> true).stream() + .filter(grant -> applicationArn.equals(grant.applicationArn())) + .sorted(Comparator.comparing(ApplicationGrant::grantType)) + .toList(); + return Pagination.paginate(matching, ApplicationGrant::grantType, + null, text(request, "NextToken"), 100, 100, "ValidationException"); + } + public synchronized void putApplicationGrant(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 15c60ef109..47deac8c68 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -631,6 +631,17 @@ void putAndGetApplicationGrantRoundTripsAuthorizationCodeGrant() { .when().post("/") .then().statusCode(200) .body("Grant.AuthorizationCode.RedirectUris[0]", equalTo("https://example.com/callback")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListApplicationGrants") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("Grants.size()", equalTo(1)) + .body("Grants[0].GrantType", equalTo("authorization_code")) + .body("Grants[0].Grant.AuthorizationCode.RedirectUris[0]", equalTo("https://example.com/callback")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 4f4151623e..0378f1ee19 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -621,6 +621,32 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void listApplicationGrantsReturnsOnlyGrantsForRequestedApplication() { + SsoApplication application = createApplication("List Grant App", "list-grant-app-token"); + SsoApplication otherApplication = createApplication("Other Grant App", "other-grant-app-token"); + ObjectNode authorizationCode = mapper.createObjectNode(); + authorizationCode.putObject("AuthorizationCode").putArray("RedirectUris").add("https://example.com/callback"); + applicationGrants.put(SsoAdminService.applicationGrantKey(application.applicationArn(), "authorization_code"), + new ApplicationGrant(application.applicationArn(), "authorization_code", authorizationCode)); + ObjectNode refreshToken = mapper.createObjectNode(); + refreshToken.putObject("RefreshToken"); + applicationGrants.put(SsoAdminService.applicationGrantKey(application.applicationArn(), "refresh_token"), + new ApplicationGrant(application.applicationArn(), "refresh_token", refreshToken)); + applicationGrants.put(SsoAdminService.applicationGrantKey(otherApplication.applicationArn(), "refresh_token"), + new ApplicationGrant(otherApplication.applicationArn(), "refresh_token", refreshToken.deepCopy())); + + ObjectNode request = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + var result = service.listApplicationGrants(request); + assertEquals(2, result.items().size()); + assertEquals("authorization_code", result.items().get(0).grantType()); + assertEquals("refresh_token", result.items().get(1).grantType()); + assertNull(result.nextToken()); + + ObjectNode badToken = request.deepCopy().put("NextToken", "not-a-token"); + assertError("ValidationException", () -> service.listApplicationGrants(badToken)); + } + @Test void putApplicationGrantValidatesUnionAndPersistsSupportedGrantTypes() { SsoApplication application = createApplication("Put Grant App", "put-grant-app-token"); From fdb4c063ae0eca9c6aecbd0118bf65e4ce574a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:18:47 -0400 Subject: [PATCH 72/99] feat(ssoadmin): get application session configuration --- .../test/SsoAdminAccountAssignmentTest.java | 4 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 12 ++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 23 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 18 +++++++++++++++ 6 files changed, 64 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 628e8ca2c4..6a14ed0b02 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -378,6 +378,10 @@ void deleteApplicationGrantUsesAwsSdk() { .name("SDK Delete Grant")) .applicationArn(); + var sessionConfiguration = sso.getApplicationSessionConfiguration(request -> request + .applicationArn(applicationArn)); + assertThat(sessionConfiguration.userBackgroundSessionApplicationStatusAsString()).isEqualTo("DISABLED"); + var putGrant = sso.putApplicationGrant(request -> request .applicationArn(applicationArn) .grantType("authorization_code") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index c99205a95b..ca47413822 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -47,6 +47,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteApplicationAuthenticationMethod` | Deletes the IAM authentication method configured for an IAM Identity Center application. | | `GetApplicationGrant` | Returns the configured OAuth 2.0 grant union for an IAM Identity Center application. | | `ListApplicationGrants` | Lists configured OAuth 2.0 grants and their union configuration for an IAM Identity Center application. | +| `GetApplicationSessionConfiguration` | Returns whether user background sessions are enabled for an IAM Identity Center application. | | `PutApplicationGrant` | Creates or updates a supported OAuth 2.0 grant configuration for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 1f2e70acd4..523b36e409 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -66,6 +66,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteApplicationAuthenticationMethod" -> deleteApplicationAuthenticationMethod(request); case "GetApplicationGrant" -> getApplicationGrant(request); case "ListApplicationGrants" -> listApplicationGrants(request); + case "GetApplicationSessionConfiguration" -> getApplicationSessionConfiguration(request); case "PutApplicationGrant" -> putApplicationGrant(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); @@ -426,6 +427,11 @@ private Response listApplicationGrants(JsonNode request) { return Response.ok(response).build(); } + private Response getApplicationSessionConfiguration(JsonNode request) { + return Response.ok(mapper.createObjectNode().put("UserBackgroundSessionApplicationStatus", + service.getApplicationSessionConfiguration(request))).build(); + } + private Response putApplicationGrant(JsonNode request) { service.putApplicationGrant(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6615c7c016..5e66c92980 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -100,6 +100,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationAssignmentConfigurations; private final StorageBackend applicationAuthenticationMethods; private final StorageBackend applicationGrants; + private final StorageBackend applicationSessionConfigurations; private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend instanceDeletionMarkers; @@ -129,6 +130,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-application-assignment-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-authentication-methods.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-grants.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-session-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-deletion-markers.json", new TypeReference>() {}), @@ -155,6 +157,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend applicationAssignmentConfigurations, StorageBackend applicationAuthenticationMethods, StorageBackend applicationGrants, + StorageBackend applicationSessionConfigurations, StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend instanceDeletionMarkers, @@ -179,6 +182,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.applicationAssignmentConfigurations = applicationAssignmentConfigurations; this.applicationAuthenticationMethods = applicationAuthenticationMethods; this.applicationGrants = applicationGrants; + this.applicationSessionConfigurations = applicationSessionConfigurations; this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.instanceDeletionMarkers = instanceDeletionMarkers; @@ -566,6 +570,7 @@ public synchronized void deleteApplication(String applicationArn) { applicationGrants.delete(key); } } + applicationSessionConfigurations.delete(applicationArn); } public boolean getApplicationAssignmentConfiguration(JsonNode request) { @@ -821,6 +826,12 @@ public synchronized void deleteApplicationAuthenticationMethod(JsonNode request) applicationAuthenticationMethods.delete(key); } + public String getApplicationSessionConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + return applicationSessionConfigurations.get(applicationArn).orElse("DISABLED"); + } + public ApplicationGrant getApplicationGrant(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); @@ -1933,6 +1944,7 @@ public void clear() { applicationAssignmentConfigurations.clear(); applicationAuthenticationMethods.clear(); applicationGrants.clear(); + applicationSessionConfigurations.clear(); instances.clear(); instanceClientTokens.clear(); instanceDeletionMarkers.clear(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 47deac8c68..0e35e4f243 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -600,6 +600,29 @@ void deleteApplicationAuthenticationMethodValidatesIamTypeAndMissingMethod() { .body("__type", org.hamcrest.Matchers.containsString("ValidationException")); } + @Test + void getApplicationSessionConfigurationReturnsDisabledForCustomApplication() { + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body("{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Session Config Integration\"}") + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationSessionConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("UserBackgroundSessionApplicationStatus", equalTo("DISABLED")); + } + @Test void putAndGetApplicationGrantRoundTripsAuthorizationCodeGrant() { String applicationArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 0378f1ee19..856af9ccb9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -67,6 +67,7 @@ void setUp() { new InMemoryStorage(), applicationAuthenticationMethods, applicationGrants, + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -621,6 +622,22 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void getApplicationSessionConfigurationDefaultsCustomApplicationsToDisabled() { + SsoApplication application = createApplication("Session Config App", "session-config-app-token"); + ObjectNode request = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + + assertEquals("DISABLED", service.getApplicationSessionConfiguration(request)); + + ObjectNode malformed = mapper.createObjectNode().put("ApplicationArn", "not-an-arn"); + assertError("ValidationException", () -> service.getApplicationSessionConfiguration(malformed)); + + ObjectNode missing = mapper.createObjectNode(); + missing.put("ApplicationArn", + "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.getApplicationSessionConfiguration(missing)); + } + @Test void listApplicationGrantsReturnsOnlyGrantsForRequestedApplication() { SsoApplication application = createApplication("List Grant App", "list-grant-app-token"); @@ -1435,6 +1452,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From e8b21d72e0edc2d5afc8f4ea06ea9c3339964f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:21:02 -0400 Subject: [PATCH 73/99] feat(ssoadmin): put application session configuration --- .../test/SsoAdminAccountAssignmentTest.java | 6 ++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++++ .../services/ssoadmin/SsoAdminService.java | 13 ++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 18 ++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 21 +++++++++++++++++++ 6 files changed, 65 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 6a14ed0b02..04f7799aa7 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -381,6 +381,12 @@ void deleteApplicationGrantUsesAwsSdk() { var sessionConfiguration = sso.getApplicationSessionConfiguration(request -> request .applicationArn(applicationArn)); assertThat(sessionConfiguration.userBackgroundSessionApplicationStatusAsString()).isEqualTo("DISABLED"); + var putSessionConfiguration = sso.putApplicationSessionConfiguration(request -> request + .applicationArn(applicationArn) + .userBackgroundSessionApplicationStatus("ENABLED")); + assertThat(putSessionConfiguration.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.getApplicationSessionConfiguration(request -> request.applicationArn(applicationArn)) + .userBackgroundSessionApplicationStatusAsString()).isEqualTo("ENABLED"); var putGrant = sso.putApplicationGrant(request -> request .applicationArn(applicationArn) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index ca47413822..b6e53ba053 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -48,6 +48,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `GetApplicationGrant` | Returns the configured OAuth 2.0 grant union for an IAM Identity Center application. | | `ListApplicationGrants` | Lists configured OAuth 2.0 grants and their union configuration for an IAM Identity Center application. | | `GetApplicationSessionConfiguration` | Returns whether user background sessions are enabled for an IAM Identity Center application. | +| `PutApplicationSessionConfiguration` | Enables or disables user background sessions for an IAM Identity Center application. | | `PutApplicationGrant` | Creates or updates a supported OAuth 2.0 grant configuration for an IAM Identity Center application. | | `DeleteApplicationGrant` | Deletes a supported OAuth 2.0 grant configuration from an IAM Identity Center application. | | `ListPermissionSets` | Lists permission sets with AWS-compatible pagination. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 523b36e409..610acf62c4 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -67,6 +67,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "GetApplicationGrant" -> getApplicationGrant(request); case "ListApplicationGrants" -> listApplicationGrants(request); case "GetApplicationSessionConfiguration" -> getApplicationSessionConfiguration(request); + case "PutApplicationSessionConfiguration" -> putApplicationSessionConfiguration(request); case "PutApplicationGrant" -> putApplicationGrant(request); case "DeleteApplicationGrant" -> deleteApplicationGrant(request); case "ListPermissionSets" -> listPermissionSets(request); @@ -432,6 +433,11 @@ private Response getApplicationSessionConfiguration(JsonNode request) { service.getApplicationSessionConfiguration(request))).build(); } + private Response putApplicationSessionConfiguration(JsonNode request) { + service.putApplicationSessionConfiguration(request); + return Response.ok().build(); + } + private Response putApplicationGrant(JsonNode request) { service.putApplicationGrant(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 5e66c92980..141eafb613 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -832,6 +832,19 @@ public String getApplicationSessionConfiguration(JsonNode request) { return applicationSessionConfigurations.get(applicationArn).orElse("DISABLED"); } + public synchronized void putApplicationSessionConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + JsonNode status = request == null ? null : request.get("UserBackgroundSessionApplicationStatus"); + if (status == null || status.isNull()) { + return; + } + if (!status.isTextual() || !("ENABLED".equals(status.textValue()) || "DISABLED".equals(status.textValue()))) { + throw validation("UserBackgroundSessionApplicationStatus must be ENABLED or DISABLED."); + } + applicationSessionConfigurations.put(applicationArn, status.textValue()); + } + public ApplicationGrant getApplicationGrant(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index 0e35e4f243..d0d8f6271f 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -621,6 +621,24 @@ void getApplicationSessionConfigurationReturnsDisabledForCustomApplication() { .when().post("/") .then().statusCode(200) .body("UserBackgroundSessionApplicationStatus", equalTo("DISABLED")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.PutApplicationSessionConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"," + + "\"UserBackgroundSessionApplicationStatus\":\"ENABLED\"}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.GetApplicationSessionConfiguration") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("UserBackgroundSessionApplicationStatus", equalTo("ENABLED")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 856af9ccb9..d299dbb6ca 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -622,6 +622,27 @@ void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { assertError("ValidationException", () -> service.deleteApplicationAuthenticationMethod(invalidType)); } + @Test + void putApplicationSessionConfigurationUpdatesBackgroundSessionStatus() { + SsoApplication application = createApplication("Put Session Config App", "put-session-config-token"); + ObjectNode request = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + request.put("UserBackgroundSessionApplicationStatus", "ENABLED"); + + service.putApplicationSessionConfiguration(request); + assertEquals("ENABLED", service.getApplicationSessionConfiguration(request)); + + request.put("UserBackgroundSessionApplicationStatus", "DISABLED"); + service.putApplicationSessionConfiguration(request); + assertEquals("DISABLED", service.getApplicationSessionConfiguration(request)); + + ObjectNode omitted = mapper.createObjectNode().put("ApplicationArn", application.applicationArn()); + service.putApplicationSessionConfiguration(omitted); + assertEquals("DISABLED", service.getApplicationSessionConfiguration(omitted)); + + ObjectNode invalid = request.deepCopy().put("UserBackgroundSessionApplicationStatus", "UNKNOWN"); + assertError("ValidationException", () -> service.putApplicationSessionConfiguration(invalid)); + } + @Test void getApplicationSessionConfigurationDefaultsCustomApplicationsToDisabled() { SsoApplication application = createApplication("Session Config App", "session-config-app-token"); From fb84dc8864441de61cc0973ecebe17216dd319d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:39:45 -0400 Subject: [PATCH 74/99] feat(ssoadmin): list resource tags --- .../test/SsoAdminAccountAssignmentTest.java | 8 ++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 14 +++ .../services/ssoadmin/SsoAdminService.java | 92 +++++++++++++++++-- .../ssoadmin/model/PermissionSet.java | 4 +- .../ssoadmin/SsoAdminIntegrationTest.java | 26 ++++++ .../ssoadmin/SsoAdminServiceTest.java | 32 +++++++ 7 files changed, 170 insertions(+), 7 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 04f7799aa7..0d7928af20 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -140,6 +140,14 @@ void createApplicationUsesAwsSdk() { .tags(tag -> tag.key("Environment").value("test"))); assertThat(replay.applicationArn()).isEqualTo(created.applicationArn()); + var tags = sso.listTagsForResource(request -> request + .instanceArn(instanceArn) + .resourceArn(created.applicationArn())); + assertThat(tags.tags()).singleElement().satisfies(tag -> { + assertThat(tag.key()).isEqualTo("Environment"); + assertThat(tag.value()).isEqualTo("test"); + }); + var described = sso.describeApplication(request -> request.applicationArn(created.applicationArn())); assertThat(described.applicationArn()).isEqualTo(created.applicationArn()); assertThat(described.instanceArn()).isEqualTo(instanceArn); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index b6e53ba053..46c6177378 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -24,6 +24,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | | `ListRegions` | Lists all enabled IAM Identity Center Regions, including primary and in-progress lifecycle states, with AWS-compatible pagination. | +| `ListTagsForResource` | Lists tags for IAM Identity Center instances, permission sets, applications, and trusted token issuers with AWS-compatible resource validation. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 610acf62c4..7738cbe5bd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -43,6 +43,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "AddRegion" -> addRegion(request); case "DescribeRegion" -> describeRegion(request); case "ListRegions" -> listRegions(request); + case "ListTagsForResource" -> listTagsForResource(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "ListApplications" -> listApplications(request, callerAccountId); @@ -260,6 +261,19 @@ private ObjectNode regionNode(io.github.hectorvent.floci.services.ssoadmin.model return response; } + private Response listTagsForResource(JsonNode request) { + var page = service.listTagsForResource(request); + ObjectNode response = mapper.createObjectNode(); + ArrayNode tags = response.putArray("Tags"); + page.items().forEach(tag -> tags.addObject() + .put("Key", tag.getKey()) + .put("Value", tag.getValue())); + if (page.nextToken() != null) { + response.put("NextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + private Response deleteApplication(JsonNode request) { service.deleteApplication(SsoAdminService.required(request, "ApplicationArn")); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 141eafb613..df5a410290 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -66,6 +66,7 @@ public class SsoAdminService implements Resettable { private static final Pattern CLIENT_TOKEN = Pattern.compile("[!-~]+"); private static final Pattern APPLICATION_URL = Pattern.compile("http(s)?://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%?=~_|]"); private static final Pattern TAG_VALUE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*"); + private static final Pattern NEXT_TOKEN = Pattern.compile("[-a-zA-Z0-9+=/_]*"); private static final Pattern INSTANCE_NAME = Pattern.compile("[\\w+=,.@-]+"); private static final Pattern ACCESS_CONTROL_ATTRIBUTE_KEY = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]+"); private static final Pattern ACCESS_CONTROL_ATTRIBUTE_SOURCE = Pattern.compile("[\\p{L}\\p{Z}\\p{N}_.:/=+\\-@\\[\\]\\{\\}$\\\\\"]*"); @@ -341,6 +342,45 @@ public PaginatedResult listTrustedTokenIssuers(JsonNode requ optionalMaxResults(request), text(request, "NextToken"), 100, 100, "ValidationException"); } + public PaginatedResult> listTagsForResource(JsonNode request) { + String instanceArn = optionalInstanceArn(request); + String resourceArn = required(request, "ResourceArn"); + if (resourceArn.length() > 2048) { + throw validation("ResourceArn is invalid."); + } + Map tags; + String resourceInstanceArn; + if (INSTANCE_ARN_PATTERN.matcher(resourceArn).matches()) { + SsoInstance instance = requireInstance(resourceArn); + tags = instance.tags(); + resourceInstanceArn = instance.instanceArn(); + } else if (PERMISSION_SET_ARN.matcher(resourceArn).matches()) { + PermissionSet permissionSet = permissionSets.get(resourceArn) + .orElseThrow(() -> notFound("Permission set not found: " + resourceArn)); + tags = permissionSet.tags(); + resourceInstanceArn = instanceArnForPermissionSet(resourceArn); + } else if (APPLICATION_ARN.matcher(resourceArn).matches()) { + SsoApplication application = getApplication(resourceArn); + tags = application.tags(); + resourceInstanceArn = application.instanceArn(); + } else if (TRUSTED_TOKEN_ISSUER_ARN.matcher(resourceArn).matches()) { + TrustedTokenIssuer issuer = getTrustedTokenIssuer(resourceArn); + tags = issuer.tags(); + resourceInstanceArn = issuer.instanceArn(); + } else { + throw validation("ResourceArn is invalid."); + } + if (instanceArn != null && !instanceArn.equals(resourceInstanceArn)) { + throw notFound("Resource not found under the specified IAM Identity Center instance."); + } + List> entries = tags.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + return Pagination.paginate(entries, Map.Entry::getKey, null, + optionalNextToken(request), 75, 75, "ValidationException"); + } + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { validateTrustedTokenIssuerArn(trustedTokenIssuerArn); return trustedTokenIssuers.get(trustedTokenIssuerArn) @@ -1091,6 +1131,7 @@ public synchronized PermissionSet createPermissionSet(JsonNode request) { String name = validateName(required(request, "Name")); String description = optionalDescription(request); String sessionDuration = validateSession(valueOr(request, "SessionDuration", "PT1H")); + Map tags = parseTags(request.get("Tags")); if (permissionSets.scan(key -> true).stream().anyMatch(p -> name.equals(p.name()))) { throw conflict("Permission set already exists: " + name); } @@ -1099,7 +1140,7 @@ public synchronized PermissionSet createPermissionSet(JsonNode request) { } String arn = "arn:aws:sso:::permissionSet/ssoins-7223b02a5d9f7c8e/ps-" + shortId(); PermissionSet permissionSet = new PermissionSet(arn, name, description, sessionDuration, - new LinkedHashMap<>(), new LinkedHashMap<>(), null, null); + new LinkedHashMap<>(), new LinkedHashMap<>(), null, null, tags); permissionSets.put(arn, permissionSet); return permissionSet; } @@ -1138,7 +1179,8 @@ public synchronized PermissionSet updatePermissionSet(JsonNode request) { ? validateSession(required(request, "SessionDuration")) : current.sessionDuration(); PermissionSet updated = new PermissionSet(current.arn(), current.name(), description, session, new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), current.permissionsBoundary()); + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), current.permissionsBoundary(), + new LinkedHashMap<>(current.tags())); permissionSets.put(updated.arn(), updated); markPermissionSetProvisioningStale(updated.arn()); return updated; @@ -1236,7 +1278,8 @@ public synchronized void putInlinePolicy(String instanceArn, String arn, String validateInlinePolicy(policy); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), policy, current.permissionsBoundary())); + new LinkedHashMap<>(current.customerManagedPolicies()), policy, current.permissionsBoundary(), + new LinkedHashMap<>(current.tags()))); markPermissionSetProvisioningStale(arn); } @@ -1244,7 +1287,8 @@ public synchronized void deleteInlinePolicy(String instanceArn, String arn) { PermissionSet current = getPermissionSet(instanceArn, arn); permissionSets.put(arn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), null, current.permissionsBoundary())); + new LinkedHashMap<>(current.customerManagedPolicies()), null, current.permissionsBoundary(), + new LinkedHashMap<>(current.tags()))); markPermissionSetProvisioningStale(arn); } @@ -1263,7 +1307,8 @@ public synchronized void deletePermissionsBoundary(String instanceArn, String pe } permissionSets.put(permissionSetArn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), null)); + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), null, + new LinkedHashMap<>(current.tags()))); markPermissionSetProvisioningStale(permissionSetArn); } @@ -1306,7 +1351,8 @@ public synchronized void putPermissionsBoundary(JsonNode request) { permissionSets.put(permissionSetArn, new PermissionSet(current.arn(), current.name(), current.description(), current.sessionDuration(), new LinkedHashMap<>(current.managedPolicies()), - new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), boundary)); + new LinkedHashMap<>(current.customerManagedPolicies()), current.inlinePolicy(), boundary, + new LinkedHashMap<>(current.tags()))); markPermissionSetProvisioningStale(permissionSetArn); } @@ -1645,6 +1691,32 @@ private static Integer optionalMaxResults(JsonNode request) { return node.intValue(); } + private String optionalInstanceArn(JsonNode request) { + if (request == null || !request.has("InstanceArn") || request.get("InstanceArn").isNull()) { + return null; + } + JsonNode node = request.get("InstanceArn"); + if (!node.isTextual()) { + throw validation("InstanceArn is invalid."); + } + return requireInstance(node.textValue()).instanceArn(); + } + + private static String optionalNextToken(JsonNode request) { + if (request == null || !request.has("NextToken") || request.get("NextToken").isNull()) { + return null; + } + JsonNode node = request.get("NextToken"); + if (!node.isTextual()) { + throw validation("NextToken is invalid."); + } + String token = node.textValue(); + if (token.length() > 2048 || !NEXT_TOKEN.matcher(token).matches()) { + throw validation("NextToken is invalid."); + } + return token; + } + private static String validateName(String name) { if (name.length() > 32 || !PERMISSION_SET_NAME.matcher(name).matches()) { throw validation("Name must be 1-32 characters and match [\\w+=,.@-]+."); @@ -1877,6 +1949,14 @@ private static String customerManagedPolicyKey(String name, String path) { return name.toLowerCase(java.util.Locale.ROOT) + "\n" + path; } + private static String instanceArnForPermissionSet(String permissionSetArn) { + int serviceIndex = permissionSetArn.indexOf(":sso::"); + int instanceStart = permissionSetArn.indexOf("permissionSet/") + "permissionSet/".length(); + int instanceEnd = permissionSetArn.indexOf('/', instanceStart); + return permissionSetArn.substring(0, serviceIndex) + ":sso:::instance/" + + permissionSetArn.substring(instanceStart, instanceEnd); + } + static String applicationAccessScopeKey(String applicationArn, String scope) { return applicationArn + "\n" + scope; } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java index b0a3236d7b..38245786da 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java @@ -9,9 +9,11 @@ public record PermissionSet(String arn, String name, String description, String Map managedPolicies, Map customerManagedPolicies, String inlinePolicy, - PermissionsBoundary permissionsBoundary) { + PermissionsBoundary permissionsBoundary, + Map tags) { public PermissionSet { managedPolicies = managedPolicies == null ? new java.util.LinkedHashMap<>() : managedPolicies; customerManagedPolicies = customerManagedPolicies == null ? new java.util.LinkedHashMap<>() : customerManagedPolicies; + tags = tags == null ? new java.util.LinkedHashMap<>() : tags; } } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index d0d8f6271f..d8e79996b1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -102,6 +102,32 @@ void addRegionReturnsAddingAndRejectsDuplicate() { .body("__type", org.hamcrest.Matchers.containsString("ConflictException")); } + @Test + void listTagsForResourceReturnsPermissionSetCreationTags() { + String instanceArn = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; + String permissionSetArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreatePermissionSet") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"TaggedIntegration\"," + + "\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"test\"}]}") + .when().post("/") + .then().statusCode(200) + .extract().path("PermissionSet.PermissionSetArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListTagsForResource") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"ResourceArn\":\"" + permissionSetArn + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Tags.size()", equalTo(1)) + .body("Tags[0].Key", equalTo("Environment")) + .body("Tags[0].Value", equalTo("test")); + } + @Test void attachCustomerManagedPolicyReferenceReturnsAnEmptyAwsResponse() { String permissionSetArn = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index d299dbb6ca..135bbfbb3b 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -1050,6 +1050,38 @@ void addRegionEnforcesTheDocumentedSixRegionQuotaIncludingPrimary() { assertError("ServiceQuotaExceededException", () -> service.addRegion(overQuota)); } + @Test + void listTagsForResourceReturnsPermissionSetTagsAndValidatesContext() { + ObjectNode create = mapper.createObjectNode(); + create.put("InstanceArn", service.getInstanceArn()); + create.put("Name", "TaggedAdmins"); + var tags = create.putArray("Tags"); + tags.addObject().put("Key", "Environment").put("Value", "test"); + tags.addObject().put("Key", "Owner").put("Value", "platform"); + PermissionSet permissionSet = service.createPermissionSet(create); + + ObjectNode request = mapper.createObjectNode(); + request.put("ResourceArn", permissionSet.arn()); + request.put("InstanceArn", service.getInstanceArn()); + var page = service.listTagsForResource(request); + assertEquals(2, page.items().size()); + assertEquals("Environment", page.items().get(0).getKey()); + assertEquals("test", page.items().get(0).getValue()); + assertEquals("Owner", page.items().get(1).getKey()); + assertNull(page.nextToken()); + + ObjectNode invalidToken = request.deepCopy(); + invalidToken.put("NextToken", "bad%token"); + assertError("ValidationException", () -> service.listTagsForResource(invalidToken)); + + ObjectNode wrongInstance = request.deepCopy(); + wrongInstance.put("InstanceArn", "arn:aws:sso:::instance/ssoins-1111111111111111"); + assertError("ResourceNotFoundException", () -> service.listTagsForResource(wrongInstance)); + + ObjectNode invalidResource = mapper.createObjectNode().put("ResourceArn", "arn:aws:s3:::bucket"); + assertError("ValidationException", () -> service.listTagsForResource(invalidResource)); + } + @Test void attachesCustomerManagedPolicyReferencesWithAwsValidationRules() { PermissionSet permissionSet = createPermissionSet("CustomerPolicyAdmins"); From 634f48fd34cf0fcb3dee0577fc7904542494e752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:44:28 -0400 Subject: [PATCH 75/99] feat(ssoadmin): tag resources --- .../test/SsoAdminAccountAssignmentTest.java | 22 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 86 ++++++++++++++----- .../ssoadmin/SsoAdminIntegrationTest.java | 21 +++++ .../ssoadmin/SsoAdminServiceTest.java | 27 ++++++ 6 files changed, 140 insertions(+), 23 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 0d7928af20..8a11d1024d 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -147,6 +147,28 @@ void createApplicationUsesAwsSdk() { assertThat(tag.key()).isEqualTo("Environment"); assertThat(tag.value()).isEqualTo("test"); }); + var tagged = sso.tagResource(request -> request + .instanceArn(instanceArn) + .resourceArn(created.applicationArn()) + .tags(tag -> tag.key("Environment").value("prod"), + tag -> tag.key("Owner").value("platform"))); + assertThat(tagged.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.listTagsForResource(request -> request.resourceArn(created.applicationArn())).tags()) + .extracting(tag -> tag.key() + "=" + tag.value()) + .containsExactly("Environment=prod", "Owner=platform"); + var replayAfterTagging = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("Floci OAuth SDK") + .clientToken("sdk-create-application-token") + .status("DISABLED") + .portalOptions(options -> options + .visibility("ENABLED") + .signInOptions(signIn -> signIn + .origin("APPLICATION") + .applicationUrl("https://example.com/login"))) + .tags(tag -> tag.key("Environment").value("test"))); + assertThat(replayAfterTagging.applicationArn()).isEqualTo(created.applicationArn()); var described = sso.describeApplication(request -> request.applicationArn(created.applicationArn())); assertThat(described.applicationArn()).isEqualTo(created.applicationArn()); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 46c6177378..7a14fb1f56 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -25,6 +25,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | | `ListRegions` | Lists all enabled IAM Identity Center Regions, including primary and in-progress lifecycle states, with AWS-compatible pagination. | | `ListTagsForResource` | Lists tags for IAM Identity Center instances, permission sets, applications, and trusted token issuers with AWS-compatible resource validation. | +| `TagResource` | Adds or overwrites tags on IAM Identity Center resources while preserving creation-time idempotency state and enforcing the current 75-tag API quota. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 7738cbe5bd..1351520b4d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -44,6 +44,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DescribeRegion" -> describeRegion(request); case "ListRegions" -> listRegions(request); case "ListTagsForResource" -> listTagsForResource(request); + case "TagResource" -> tagResource(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "ListApplications" -> listApplications(request, callerAccountId); @@ -274,6 +275,11 @@ private Response listTagsForResource(JsonNode request) { return Response.ok(response).build(); } + private Response tagResource(JsonNode request) { + service.tagResource(request); + return Response.ok().build(); + } + private Response deleteApplication(JsonNode request) { service.deleteApplication(SsoAdminService.required(request, "ApplicationArn")); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index df5a410290..4275c8b894 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -102,6 +102,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationAuthenticationMethods; private final StorageBackend applicationGrants; private final StorageBackend applicationSessionConfigurations; + private final StorageBackend> resourceTagOverrides; private final StorageBackend instances; private final StorageBackend instanceClientTokens; private final StorageBackend instanceDeletionMarkers; @@ -132,6 +133,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-application-authentication-methods.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-grants.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-session-configurations.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-resource-tag-overrides.json", new TypeReference>>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-deletion-markers.json", new TypeReference>() {}), @@ -159,6 +161,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend applicationAuthenticationMethods, StorageBackend applicationGrants, StorageBackend applicationSessionConfigurations, + StorageBackend> resourceTagOverrides, StorageBackend instances, StorageBackend instanceClientTokens, StorageBackend instanceDeletionMarkers, @@ -184,6 +187,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.applicationAuthenticationMethods = applicationAuthenticationMethods; this.applicationGrants = applicationGrants; this.applicationSessionConfigurations = applicationSessionConfigurations; + this.resourceTagOverrides = resourceTagOverrides; this.instances = instances; this.instanceClientTokens = instanceClientTokens; this.instanceDeletionMarkers = instanceDeletionMarkers; @@ -345,42 +349,69 @@ public PaginatedResult listTrustedTokenIssuers(JsonNode requ public PaginatedResult> listTagsForResource(JsonNode request) { String instanceArn = optionalInstanceArn(request); String resourceArn = required(request, "ResourceArn"); - if (resourceArn.length() > 2048) { + TaggableResource resource = resolveTaggableResource(resourceArn); + validateResourceInstance(instanceArn, resource.instanceArn()); + Map tags = resourceTagOverrides.get(resourceArn) + .orElseGet(() -> resource.tags()); + List> entries = tags.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), entry.getValue())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + return Pagination.paginate(entries, Map.Entry::getKey, null, + optionalNextToken(request), 75, 75, "ValidationException"); + } + + public synchronized void tagResource(JsonNode request) { + String instanceArn = optionalInstanceArn(request); + String resourceArn = required(request, "ResourceArn"); + TaggableResource resource = resolveTaggableResource(resourceArn); + validateResourceInstance(instanceArn, resource.instanceArn()); + JsonNode tagsNode = request == null ? null : request.get("Tags"); + if (tagsNode == null || tagsNode.isNull()) { + throw validation("Tags is required."); + } + Map requestedTags = parseTags(tagsNode); + Map updated = new LinkedHashMap<>(resourceTagOverrides.get(resourceArn) + .orElseGet(resource::tags)); + updated.putAll(requestedTags); + if (updated.size() > 75) { + throw quota("A resource can have at most 75 tags."); + } + resourceTagOverrides.put(resourceArn, updated); + } + + private TaggableResource resolveTaggableResource(String resourceArn) { + if (resourceArn == null || resourceArn.length() < 10 || resourceArn.length() > 2048) { throw validation("ResourceArn is invalid."); } - Map tags; - String resourceInstanceArn; if (INSTANCE_ARN_PATTERN.matcher(resourceArn).matches()) { SsoInstance instance = requireInstance(resourceArn); - tags = instance.tags(); - resourceInstanceArn = instance.instanceArn(); - } else if (PERMISSION_SET_ARN.matcher(resourceArn).matches()) { + return new TaggableResource(instance.tags(), instance.instanceArn()); + } + if (PERMISSION_SET_ARN.matcher(resourceArn).matches()) { PermissionSet permissionSet = permissionSets.get(resourceArn) .orElseThrow(() -> notFound("Permission set not found: " + resourceArn)); - tags = permissionSet.tags(); - resourceInstanceArn = instanceArnForPermissionSet(resourceArn); - } else if (APPLICATION_ARN.matcher(resourceArn).matches()) { + return new TaggableResource(permissionSet.tags(), instanceArnForPermissionSet(resourceArn)); + } + if (APPLICATION_ARN.matcher(resourceArn).matches()) { SsoApplication application = getApplication(resourceArn); - tags = application.tags(); - resourceInstanceArn = application.instanceArn(); - } else if (TRUSTED_TOKEN_ISSUER_ARN.matcher(resourceArn).matches()) { + return new TaggableResource(application.tags(), application.instanceArn()); + } + if (TRUSTED_TOKEN_ISSUER_ARN.matcher(resourceArn).matches()) { TrustedTokenIssuer issuer = getTrustedTokenIssuer(resourceArn); - tags = issuer.tags(); - resourceInstanceArn = issuer.instanceArn(); - } else { - throw validation("ResourceArn is invalid."); + return new TaggableResource(issuer.tags(), issuer.instanceArn()); } - if (instanceArn != null && !instanceArn.equals(resourceInstanceArn)) { + throw validation("ResourceArn is invalid."); + } + + private static void validateResourceInstance(String requestedInstanceArn, String resourceInstanceArn) { + if (requestedInstanceArn != null && !requestedInstanceArn.equals(resourceInstanceArn)) { throw notFound("Resource not found under the specified IAM Identity Center instance."); } - List> entries = tags.entrySet().stream() - .map(entry -> Map.entry(entry.getKey(), entry.getValue())) - .sorted(Map.Entry.comparingByKey()) - .toList(); - return Pagination.paginate(entries, Map.Entry::getKey, null, - optionalNextToken(request), 75, 75, "ValidationException"); } + private record TaggableResource(Map tags, String instanceArn) {} + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { validateTrustedTokenIssuerArn(trustedTokenIssuerArn); return trustedTokenIssuers.get(trustedTokenIssuerArn) @@ -391,6 +422,7 @@ public synchronized void deleteTrustedTokenIssuer(JsonNode request) { String trustedTokenIssuerArn = required(request, "TrustedTokenIssuerArn"); getTrustedTokenIssuer(trustedTokenIssuerArn); trustedTokenIssuers.delete(trustedTokenIssuerArn); + resourceTagOverrides.delete(trustedTokenIssuerArn); for (String key : new ArrayList<>(trustedTokenIssuerClientTokens.keys())) { if (trustedTokenIssuerArn.equals(trustedTokenIssuerClientTokens.get(key).orElse(null))) { trustedTokenIssuerClientTokens.delete(key); @@ -547,6 +579,9 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId for (String applicationArn : applicationArns) { deleteApplication(applicationArn); } + for (String permissionSetArn : new ArrayList<>(permissionSets.keys())) { + resourceTagOverrides.delete(permissionSetArn); + } permissionSets.clear(); assignments.clear(); @@ -559,6 +594,7 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId TrustedTokenIssuer issuer = trustedTokenIssuers.get(key).orElse(null); if (issuer != null && instanceArn.equals(issuer.instanceArn())) { trustedTokenIssuers.delete(key); + resourceTagOverrides.delete(key); } } for (String key : new java.util.ArrayList<>(trustedTokenIssuerClientTokens.keys())) { @@ -574,12 +610,14 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId } identityStoreService.deleteIdentityStore(instance.identityStoreId()); instances.delete(callerAccountId); + resourceTagOverrides.delete(instanceArn); instanceDeletionMarkers.put(callerAccountId, true); } public synchronized void deleteApplication(String applicationArn) { getApplication(applicationArn); applications.delete(applicationArn); + resourceTagOverrides.delete(applicationArn); for (String key : new java.util.ArrayList<>(applicationAssignments.keys())) { ApplicationAssignment assignment = applicationAssignments.get(key).orElse(null); if (assignment != null && applicationArn.equals(assignment.applicationArn())) { @@ -1158,6 +1196,7 @@ public synchronized void deletePermissionSet(String instanceArn, String permissi } getPermissionSet(instanceArn, permissionSetArn); permissionSets.delete(permissionSetArn); + resourceTagOverrides.delete(permissionSetArn); for (String key : new ArrayList<>(assignments.keys())) { Assignment assignment = assignments.get(key).orElse(null); if (assignment != null && permissionSetArn.equals(assignment.permissionSetArn())) { @@ -2038,6 +2077,7 @@ public void clear() { applicationAuthenticationMethods.clear(); applicationGrants.clear(); applicationSessionConfigurations.clear(); + resourceTagOverrides.clear(); instances.clear(); instanceClientTokens.clear(); instanceDeletionMarkers.clear(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index d8e79996b1..d89537dfff 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -126,6 +126,27 @@ void listTagsForResourceReturnsPermissionSetCreationTags() { .body("Tags.size()", equalTo(1)) .body("Tags[0].Key", equalTo("Environment")) .body("Tags[0].Value", equalTo("test")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.TagResource") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"ResourceArn\":\"" + permissionSetArn + "\"," + + "\"Tags\":[{\"Key\":\"Environment\",\"Value\":\"prod\"},{\"Key\":\"Owner\",\"Value\":\"platform\"}]}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListTagsForResource") + .body("{\"ResourceArn\":\"" + permissionSetArn + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Tags.size()", equalTo(2)) + .body("Tags[0].Key", equalTo("Environment")) + .body("Tags[0].Value", equalTo("prod")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 135bbfbb3b..0c841e33b9 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -68,6 +68,7 @@ void setUp() { applicationAuthenticationMethods, applicationGrants, new InMemoryStorage(), + new InMemoryStorage>(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -1070,6 +1071,31 @@ void listTagsForResourceReturnsPermissionSetTagsAndValidatesContext() { assertEquals("Owner", page.items().get(1).getKey()); assertNull(page.nextToken()); + ObjectNode tag = mapper.createObjectNode(); + tag.put("ResourceArn", permissionSet.arn()); + tag.put("InstanceArn", service.getInstanceArn()); + tag.putArray("Tags") + .addObject().put("Key", "Environment").put("Value", "prod"); + service.tagResource(tag); + var updated = service.listTagsForResource(request); + assertEquals(2, updated.items().size()); + assertEquals("prod", updated.items().get(0).getValue()); + + ObjectNode fillToQuota = mapper.createObjectNode(); + fillToQuota.put("ResourceArn", permissionSet.arn()); + var quotaTags = fillToQuota.putArray("Tags"); + for (int i = 0; i < 73; i++) { + quotaTags.addObject().put("Key", "K" + i).put("Value", "v"); + } + service.tagResource(fillToQuota); + ObjectNode exceedQuota = mapper.createObjectNode(); + exceedQuota.put("ResourceArn", permissionSet.arn()); + exceedQuota.putArray("Tags").addObject().put("Key", "K73").put("Value", "v"); + assertError("ServiceQuotaExceededException", () -> service.tagResource(exceedQuota)); + + ObjectNode missingTags = mapper.createObjectNode().put("ResourceArn", permissionSet.arn()); + assertError("ValidationException", () -> service.tagResource(missingTags)); + ObjectNode invalidToken = request.deepCopy(); invalidToken.put("NextToken", "bad%token"); assertError("ValidationException", () -> service.listTagsForResource(invalidToken)); @@ -1506,6 +1532,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage>(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 20f7e89e03a18c62f97b182cab9543a26a9fb4d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:46:31 -0400 Subject: [PATCH 76/99] feat(ssoadmin): untag resources --- .../test/SsoAdminAccountAssignmentTest.java | 7 +++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 +++++ .../services/ssoadmin/SsoAdminService.java | 26 +++++++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 19 ++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 10 +++++++ 6 files changed, 69 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 8a11d1024d..3a8a494212 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -156,6 +156,13 @@ void createApplicationUsesAwsSdk() { assertThat(sso.listTagsForResource(request -> request.resourceArn(created.applicationArn())).tags()) .extracting(tag -> tag.key() + "=" + tag.value()) .containsExactly("Environment=prod", "Owner=platform"); + var untagged = sso.untagResource(request -> request + .resourceArn(created.applicationArn()) + .tagKeys("Owner")); + assertThat(untagged.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.listTagsForResource(request -> request.resourceArn(created.applicationArn())).tags()) + .extracting(tag -> tag.key() + "=" + tag.value()) + .containsExactly("Environment=prod"); var replayAfterTagging = sso.createApplication(request -> request .instanceArn(instanceArn) .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 7a14fb1f56..d37a48e4d5 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -26,6 +26,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListRegions` | Lists all enabled IAM Identity Center Regions, including primary and in-progress lifecycle states, with AWS-compatible pagination. | | `ListTagsForResource` | Lists tags for IAM Identity Center instances, permission sets, applications, and trusted token issuers with AWS-compatible resource validation. | | `TagResource` | Adds or overwrites tags on IAM Identity Center resources while preserving creation-time idempotency state and enforcing the current 75-tag API quota. | +| `UntagResource` | Removes one or more tag keys from IAM Identity Center resources with AWS-compatible validation and idempotent removal semantics. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 1351520b4d..21a55789a1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -45,6 +45,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListRegions" -> listRegions(request); case "ListTagsForResource" -> listTagsForResource(request); case "TagResource" -> tagResource(request); + case "UntagResource" -> untagResource(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); case "DescribeApplication" -> describeApplication(request); case "ListApplications" -> listApplications(request, callerAccountId); @@ -280,6 +281,11 @@ private Response tagResource(JsonNode request) { return Response.ok().build(); } + private Response untagResource(JsonNode request) { + service.untagResource(request); + return Response.ok().build(); + } + private Response deleteApplication(JsonNode request) { service.deleteApplication(SsoAdminService.required(request, "ApplicationArn")); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 4275c8b894..ca59b36408 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -380,6 +380,32 @@ public synchronized void tagResource(JsonNode request) { resourceTagOverrides.put(resourceArn, updated); } + public synchronized void untagResource(JsonNode request) { + String instanceArn = optionalInstanceArn(request); + String resourceArn = required(request, "ResourceArn"); + TaggableResource resource = resolveTaggableResource(resourceArn); + validateResourceInstance(instanceArn, resource.instanceArn()); + JsonNode tagKeysNode = request == null ? null : request.get("TagKeys"); + if (tagKeysNode == null || !tagKeysNode.isArray() || tagKeysNode.size() < 1 || tagKeysNode.size() > 75) { + throw validation("TagKeys must contain between 1 and 75 entries."); + } + List tagKeys = new ArrayList<>(); + for (JsonNode keyNode : tagKeysNode) { + if (!keyNode.isTextual()) { + throw validation("TagKeys values must be strings."); + } + String key = keyNode.textValue(); + if (key.isEmpty() || key.length() > 128 || !TAG_VALUE.matcher(key).matches()) { + throw validation("Tag key is invalid."); + } + tagKeys.add(key); + } + Map updated = new LinkedHashMap<>(resourceTagOverrides.get(resourceArn) + .orElseGet(resource::tags)); + tagKeys.forEach(updated::remove); + resourceTagOverrides.put(resourceArn, updated); + } + private TaggableResource resolveTaggableResource(String resourceArn) { if (resourceArn == null || resourceArn.length() < 10 || resourceArn.length() > 2048) { throw validation("ResourceArn is invalid."); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index d89537dfff..ef4f1fa6e1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -147,6 +147,25 @@ void listTagsForResourceReturnsPermissionSetCreationTags() { .body("Tags.size()", equalTo(2)) .body("Tags[0].Key", equalTo("Environment")) .body("Tags[0].Value", equalTo("prod")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.UntagResource") + .body("{\"ResourceArn\":\"" + permissionSetArn + "\",\"TagKeys\":[\"Environment\"]}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.ListTagsForResource") + .body("{\"ResourceArn\":\"" + permissionSetArn + "\"}") + .when().post("/") + .then() + .statusCode(200) + .body("Tags.size()", equalTo(1)) + .body("Tags[0].Key", equalTo("Owner")); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 0c841e33b9..102e423bd7 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -1096,6 +1096,16 @@ void listTagsForResourceReturnsPermissionSetTagsAndValidatesContext() { ObjectNode missingTags = mapper.createObjectNode().put("ResourceArn", permissionSet.arn()); assertError("ValidationException", () -> service.tagResource(missingTags)); + ObjectNode untag = mapper.createObjectNode(); + untag.put("ResourceArn", permissionSet.arn()); + untag.putArray("TagKeys").add("Environment").add("does-not-exist"); + service.untagResource(untag); + assertTrue(service.listTagsForResource(request).items().stream() + .noneMatch(entry -> "Environment".equals(entry.getKey()))); + + ObjectNode missingTagKeys = mapper.createObjectNode().put("ResourceArn", permissionSet.arn()); + assertError("ValidationException", () -> service.untagResource(missingTagKeys)); + ObjectNode invalidToken = request.deepCopy(); invalidToken.put("NextToken", "bad%token"); assertError("ValidationException", () -> service.listTagsForResource(invalidToken)); From 867c6e51f5ab36eb9c399ad3b46b6701eb177a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:48:32 -0400 Subject: [PATCH 77/99] feat(ssoadmin): remove regions --- .../test/SsoAdminAccountAssignmentTest.java | 10 +++++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++++ .../services/ssoadmin/SsoAdminService.java | 21 ++++++++++++++ .../ssoadmin/SsoAdminIntegrationTest.java | 29 +++++++++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 24 +++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 3a8a494212..dbd8d494ac 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -41,6 +41,16 @@ void addRegionUsesAwsSdk() { assertThat(region.statusAsString()).isEqualTo("ACTIVE"); }); + var removed = sso.removeRegion(request -> request + .instanceArn(instanceArn) + .regionName("ap-southeast-3")); + assertThat(removed.statusAsString()).isEqualTo("REMOVING"); + assertThatThrownBy(() -> sso.describeRegion(request -> request + .instanceArn(instanceArn) + .regionName("ap-southeast-3"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ResourceNotFoundException.class); + + sso.addRegion(request -> request.instanceArn(instanceArn).regionName("ap-southeast-3")); assertThatThrownBy(() -> sso.addRegion(request -> request .instanceArn(instanceArn) .regionName("ap-southeast-3"))) diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d37a48e4d5..477b14a91a 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -22,6 +22,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `ListTrustedTokenIssuers` | Lists trusted token issuer metadata for an IAM Identity Center instance with AWS-compatible pagination. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | | `AddRegion` | Adds a Region to the local IAM Identity Center instance and reports the initial `ADDING` status. | +| `RemoveRegion` | Removes an additional IAM Identity Center Region from the primary Region, reports `REMOVING`, and completes the local asynchronous workflow immediately. | | `DescribeRegion` | Returns the enabled Region name, status, added date, and primary-Region flag for an IAM Identity Center instance. | | `ListRegions` | Lists all enabled IAM Identity Center Regions, including primary and in-progress lifecycle states, with AWS-compatible pagination. | | `ListTagsForResource` | Lists tags for IAM Identity Center instances, permission sets, applications, and trusted token issuers with AWS-compatible resource validation. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 21a55789a1..454a3bbdd5 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -41,6 +41,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListTrustedTokenIssuers" -> listTrustedTokenIssuers(request); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); case "AddRegion" -> addRegion(request); + case "RemoveRegion" -> removeRegion(request, region); case "DescribeRegion" -> describeRegion(request); case "ListRegions" -> listRegions(request); case "ListTagsForResource" -> listTagsForResource(request); @@ -239,6 +240,11 @@ private Response addRegion(JsonNode request) { return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); } + private Response removeRegion(JsonNode request, String requestRegion) { + var region = service.removeRegion(request, requestRegion); + return Response.ok(mapper.createObjectNode().put("Status", region.status())).build(); + } + private Response describeRegion(JsonNode request) { return Response.ok(regionNode(service.describeRegion(request))).build(); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index ca59b36408..6dee3fdb57 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1169,6 +1169,27 @@ public synchronized RegionMetadata addRegion(JsonNode request) { return response; } + public synchronized RegionMetadata removeRegion(JsonNode request, String requestRegion) { + SsoInstance instance = requireInstance(required(request, "InstanceArn")); + String regionName = validateRegionName(required(request, "RegionName")); + if (!instance.primaryRegion().equals(requestRegion)) { + throw accessDenied("RemoveRegion must be called from the primary IAM Identity Center Region."); + } + if (instance.primaryRegion().equals(regionName)) { + throw conflict("The primary IAM Identity Center Region cannot be removed."); + } + RegionMetadata current = regions.get(regionName) + .orElseThrow(() -> notFound("IAM Identity Center Region not found: " + regionName)); + boolean workflowInProgress = regions.scan(key -> true).stream() + .anyMatch(region -> "ADDING".equals(region.status()) || "REMOVING".equals(region.status())); + if (workflowInProgress) { + throw conflict("Another IAM Identity Center Region add or remove workflow is already in progress."); + } + RegionMetadata response = new RegionMetadata(regionName, "REMOVING", current.addedDate(), false); + regions.delete(regionName); + return response; + } + public PaginatedResult listPermissionSets(JsonNode request) { requireInstance(required(request, "InstanceArn")); return Pagination.paginate(permissionSets.scan(key -> true), PermissionSet::arn, diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index ef4f1fa6e1..fdbe3434db 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -102,6 +102,35 @@ void addRegionReturnsAddingAndRejectsDuplicate() { .body("__type", org.hamcrest.Matchers.containsString("ConflictException")); } + @Test + void removeRegionReturnsRemovingAndCompletesLocalRemoval() { + String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\",\"RegionName\":\"ap-south-2\"}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.AddRegion") + .body(request) + .when().post("/") + .then().statusCode(200).body("Status", equalTo("ADDING")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.RemoveRegion") + .body(request) + .when().post("/") + .then().statusCode(200).body("Status", equalTo("REMOVING")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeRegion") + .body(request) + .when().post("/") + .then().statusCode(400) + .body("__type", org.hamcrest.Matchers.containsString("ResourceNotFoundException")); + } + @Test void listTagsForResourceReturnsPermissionSetCreationTags() { String instanceArn = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 102e423bd7..138542ef53 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -1036,6 +1036,30 @@ void addRegionValidatesAndRejectsDuplicates() { assertError("ValidationException", () -> service.addRegion(invalidRegion)); } + @Test + void removeRegionReturnsRemovingAndDeletesAdditionalRegion() { + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("RegionName", "us-west-2"); + service.addRegion(request); + + RegionMetadata removing = service.removeRegion(request, "us-east-1"); + assertEquals("us-west-2", removing.regionName()); + assertEquals("REMOVING", removing.status()); + assertFalse(removing.primaryRegion()); + assertError("ResourceNotFoundException", () -> service.describeRegion(request)); + + ObjectNode primary = request.deepCopy().put("RegionName", "us-east-1"); + assertError("ConflictException", () -> service.removeRegion(primary, "us-east-1")); + + ObjectNode missing = request.deepCopy().put("RegionName", "eu-west-3"); + assertError("ResourceNotFoundException", () -> service.removeRegion(missing, "us-east-1")); + + ObjectNode wrongRegion = request.deepCopy().put("RegionName", "eu-west-1"); + service.addRegion(wrongRegion); + assertError("AccessDeniedException", () -> service.removeRegion(wrongRegion, "us-west-2")); + } + @Test void addRegionEnforcesTheDocumentedSixRegionQuotaIncludingPrimary() { for (String region : java.util.List.of("us-west-1", "us-west-2", "eu-west-1", "eu-central-1", "ap-south-1")) { From 8e8bcad29a00fff6cda77c3b4f843175714ae970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:51:52 -0400 Subject: [PATCH 78/99] feat(ssoadmin): update applications --- .../test/SsoAdminAccountAssignmentTest.java | 12 ++++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 66 ++++++++++++++++++- .../ssoadmin/SsoAdminIntegrationTest.java | 43 ++++++++++++ .../ssoadmin/SsoAdminServiceTest.java | 37 +++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index dbd8d494ac..50ecbcd44b 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -187,8 +187,20 @@ void createApplicationUsesAwsSdk() { .tags(tag -> tag.key("Environment").value("test"))); assertThat(replayAfterTagging.applicationArn()).isEqualTo(created.applicationArn()); + var updatedApplication = sso.updateApplication(request -> request + .applicationArn(created.applicationArn()) + .name("Floci OAuth SDK Updated") + .description("Updated through SDK") + .status("ENABLED") + .portalOptions(options -> options.signInOptions(signIn -> signIn + .origin("IDENTITY_CENTER")))); + assertThat(updatedApplication.sdkHttpResponse().isSuccessful()).isTrue(); + var described = sso.describeApplication(request -> request.applicationArn(created.applicationArn())); assertThat(described.applicationArn()).isEqualTo(created.applicationArn()); + assertThat(described.name()).isEqualTo("Floci OAuth SDK Updated"); + assertThat(described.description()).isEqualTo("Updated through SDK"); + assertThat(described.statusAsString()).isEqualTo("ENABLED"); assertThat(described.instanceArn()).isEqualTo(instanceArn); assertThat(described.applicationAccount()).isEqualTo("000000000000"); assertThat(described.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 477b14a91a..86706c840d 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -29,6 +29,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `TagResource` | Adds or overwrites tags on IAM Identity Center resources while preserving creation-time idempotency state and enforcing the current 75-tag API quota. | | `UntagResource` | Removes one or more tag keys from IAM Identity Center resources with AWS-compatible validation and idempotent removal semantics. | | `CreateApplication` | Creates a customer managed OAuth 2.0 application with AWS-compatible idempotency, portal options, status, and tags. | +| `UpdateApplication` | Updates the mutable name, description, status, and sign-in options of an IAM Identity Center application while preserving creation-time idempotency state. | | `DescribeApplication` | Returns the full persisted IAM Identity Center application metadata, including portal options, status, creation Region, and identity store ARN. | | `ListApplications` | Lists applications for an IAM Identity Center instance with AWS-compatible account/provider filters, member-account access rules, pagination, and full application metadata. | | `CreateApplicationAssignment` | Grants direct application access to a user or group. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 454a3bbdd5..841d1964b7 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -48,6 +48,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "TagResource" -> tagResource(request); case "UntagResource" -> untagResource(request); case "CreateApplication" -> createApplication(request, callerAccountId, region); + case "UpdateApplication" -> updateApplication(request); case "DescribeApplication" -> describeApplication(request); case "ListApplications" -> listApplications(request, callerAccountId); case "CreateApplicationAssignment" -> createApplicationAssignment(request); @@ -489,6 +490,11 @@ private Response createApplication(JsonNode request, String callerAccountId, Str return Response.ok(response).build(); } + private Response updateApplication(JsonNode request) { + service.updateApplication(request); + return Response.ok().build(); + } + private Response describeApplication(JsonNode request) { return Response.ok(applicationNode(service.describeApplication(request))).build(); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 6dee3fdb57..2829d3d71f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -95,6 +95,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend permissionSetProvisioningOperations; private final StorageBackend regions; private final StorageBackend applications; + private final StorageBackend applicationUpdateOverrides; private final StorageBackend applicationClientTokens; private final StorageBackend applicationAssignments; private final StorageBackend applicationAccessScopes; @@ -126,6 +127,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-permission-set-provisioning-operations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-regions.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-applications.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-application-update-overrides.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-assignments.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-application-access-scopes.json", new TypeReference>() {}), @@ -154,6 +156,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend permissionSetProvisioningOperations, StorageBackend regions, StorageBackend applications, + StorageBackend applicationUpdateOverrides, StorageBackend applicationClientTokens, StorageBackend applicationAssignments, StorageBackend applicationAccessScopes, @@ -180,6 +183,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.permissionSetProvisioningOperations = permissionSetProvisioningOperations; this.regions = regions; this.applications = applications; + this.applicationUpdateOverrides = applicationUpdateOverrides; this.applicationClientTokens = applicationClientTokens; this.applicationAssignments = applicationAssignments; this.applicationAccessScopes = applicationAccessScopes; @@ -643,6 +647,7 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId public synchronized void deleteApplication(String applicationArn) { getApplication(applicationArn); applications.delete(applicationArn); + applicationUpdateOverrides.delete(applicationArn); resourceTagOverrides.delete(applicationArn); for (String key : new java.util.ArrayList<>(applicationAssignments.keys())) { ApplicationAssignment assignment = applicationAssignments.get(key).orElse(null); @@ -1139,6 +1144,32 @@ public synchronized SsoApplication createApplication(JsonNode request, String ca return application; } + public synchronized SsoApplication updateApplication(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + SsoApplication current = getApplication(applicationArn); + String name = request != null && request.has("Name") + ? optionalString(request, "Name", 1, 100) : current.name(); + String description = request != null && request.has("Description") + ? optionalString(request, "Description", 1, 128) : current.description(); + String status = current.status(); + if (request != null && request.has("Status") && !request.get("Status").isNull()) { + status = text(request, "Status"); + if (!Set.of("ENABLED", "DISABLED").contains(status)) { + throw validation("Status must be ENABLED or DISABLED."); + } + } + ApplicationPortalOptions portalOptions = current.portalOptions(); + if (request != null && request.has("PortalOptions") && !request.get("PortalOptions").isNull()) { + portalOptions = parseUpdatePortalOptions(request.get("PortalOptions"), current.portalOptions()); + } + SsoApplication updated = new SsoApplication( + current.applicationAccount(), current.applicationArn(), current.applicationProviderArn(), + current.createdDateEpochMillis(), current.createdFrom(), description, current.identityStoreArn(), + current.instanceArn(), name, portalOptions, status, current.tags()); + applicationUpdateOverrides.put(applicationArn, updated); + return updated; + } + public RegionMetadata describeRegion(JsonNode request) { SsoInstance instance = requireInstance(required(request, "InstanceArn")); String regionName = validateRegionName(required(request, "RegionName")); @@ -1902,6 +1933,7 @@ public PaginatedResult listApplications(JsonNode request, String String finalApplicationAccount = applicationAccount; String finalApplicationProvider = applicationProvider; List matching = applications.scan(key -> true).stream() + .map(application -> applicationUpdateOverrides.get(application.applicationArn()).orElse(application)) .filter(application -> instanceArn.equals(application.instanceArn())) .filter(application -> finalApplicationAccount == null || finalApplicationAccount.equals(application.applicationAccount())) @@ -1920,7 +1952,9 @@ public PaginatedResult listApplications(JsonNode request, String SsoApplication getApplication(String applicationArn) { validateApplicationArn(applicationArn); - return applications.get(applicationArn).orElseThrow(() -> notFound("Application not found: " + applicationArn)); + SsoApplication base = applications.get(applicationArn) + .orElseThrow(() -> notFound("Application not found: " + applicationArn)); + return applicationUpdateOverrides.get(applicationArn).orElse(base); } private static String validateApplicationArn(String arn) { @@ -1945,6 +1979,35 @@ private static boolean applicationMatches(SsoApplication application, String pro && java.util.Objects.equals(application.tags(), tags); } + private static ApplicationPortalOptions parseUpdatePortalOptions(JsonNode node, ApplicationPortalOptions current) { + if (!node.isObject()) { + throw validation("PortalOptions must be an object."); + } + if (node.has("Visibility")) { + throw validation("PortalOptions.Visibility cannot be updated by UpdateApplication."); + } + ApplicationSignInOptions signInOptions = current == null ? null : current.signInOptions(); + if (node.has("SignInOptions") && !node.get("SignInOptions").isNull()) { + JsonNode signIn = node.get("SignInOptions"); + if (!signIn.isObject()) { + throw validation("PortalOptions.SignInOptions must be an object."); + } + String origin = required(signIn, "Origin"); + if (!Set.of("IDENTITY_CENTER", "APPLICATION").contains(origin)) { + throw validation("SignInOptions.Origin must be IDENTITY_CENTER or APPLICATION."); + } + String applicationUrl = text(signIn, "ApplicationUrl"); + if ("APPLICATION".equals(origin) && applicationUrl == null) { + throw validation("SignInOptions.ApplicationUrl is required when Origin is APPLICATION."); + } + if (applicationUrl != null && (applicationUrl.length() > 512 || !APPLICATION_URL.matcher(applicationUrl).matches())) { + throw validation("SignInOptions.ApplicationUrl is invalid."); + } + signInOptions = new ApplicationSignInOptions(origin, applicationUrl); + } + return new ApplicationPortalOptions(current == null ? null : current.visibility(), signInOptions); + } + private static ApplicationPortalOptions parsePortalOptions(JsonNode node) { if (node == null || node.isNull()) { return null; @@ -2117,6 +2180,7 @@ public void clear() { permissionSetProvisioningOperations.clear(); regions.clear(); applications.clear(); + applicationUpdateOverrides.clear(); applicationClientTokens.clear(); applicationAssignments.clear(); applicationAccessScopes.clear(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index fdbe3434db..efa3a5c6ad 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -303,6 +303,49 @@ void describeApplicationReturnsAwsApplicationShape() { .body("Applications.ApplicationArn", org.hamcrest.Matchers.hasItem(applicationArn)); } + @Test + void updateApplicationChangesMutableFieldsAndDescribeReturnsThem() { + String createRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," + + "\"ApplicationProviderArn\":\"arn:aws:sso::aws:applicationProvider/custom\"," + + "\"Name\":\"Update Integration\",\"ClientToken\":\"update-integration-token\"}"; + String applicationArn = given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(createRequest) + .when().post("/") + .then().statusCode(200) + .extract().path("ApplicationArn"); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.UpdateApplication") + .body("{\"ApplicationArn\":\"" + applicationArn + "\",\"Name\":\"Updated Integration\"," + + "\"Description\":\"Updated application\",\"Status\":\"DISABLED\"}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeApplication") + .body("{\"ApplicationArn\":\"" + applicationArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("Name", equalTo("Updated Integration")) + .body("Description", equalTo("Updated application")) + .body("Status", equalTo("DISABLED")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateApplication") + .body(createRequest) + .when().post("/") + .then().statusCode(200).body("ApplicationArn", equalTo(applicationArn)); + } + @Test void createApplicationAssignmentGrantsDirectAccessWithEmptyResponse() { String appRequest = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\"," diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 138542ef53..0eca4405d1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -61,6 +61,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), applicationAccessScopes, @@ -972,6 +973,41 @@ void createApplicationSupportsOAuthProviderPortalOptionsTagsAndIdempotency() { assertError("IdempotentParameterMismatch", () -> service.createApplication(mismatch, ACCOUNT_ID, "us-east-1")); } + @Test + void updateApplicationChangesOnlyDocumentedMutableFieldsAndPreservesCreateIdempotency() { + ObjectNode create = mapper.createObjectNode(); + create.put("InstanceArn", service.getInstanceArn()); + create.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + create.put("Name", "Original App"); + create.put("Description", "Original description"); + create.put("ClientToken", "update-app-token"); + create.put("Status", "ENABLED"); + create.putObject("PortalOptions").put("Visibility", "ENABLED") + .putObject("SignInOptions").put("Origin", "IDENTITY_CENTER"); + SsoApplication created = service.createApplication(create, ACCOUNT_ID, "us-east-1"); + + ObjectNode update = mapper.createObjectNode(); + update.put("ApplicationArn", created.applicationArn()); + update.put("Name", "Updated App"); + update.put("Description", "Updated description"); + update.put("Status", "DISABLED"); + update.putObject("PortalOptions").putObject("SignInOptions") + .put("Origin", "APPLICATION").put("ApplicationUrl", "https://example.com/new-login"); + SsoApplication updated = service.updateApplication(update); + assertEquals("Updated App", updated.name()); + assertEquals("Updated description", updated.description()); + assertEquals("DISABLED", updated.status()); + assertEquals("ENABLED", updated.portalOptions().visibility()); + assertEquals("APPLICATION", updated.portalOptions().signInOptions().origin()); + assertEquals("https://example.com/new-login", updated.portalOptions().signInOptions().applicationUrl()); + assertEquals("Updated App", service.describeApplication(update).name()); + assertEquals(created.applicationArn(), service.createApplication(create, ACCOUNT_ID, "us-east-1").applicationArn()); + + ObjectNode invalidVisibility = mapper.createObjectNode().put("ApplicationArn", created.applicationArn()); + invalidVisibility.putObject("PortalOptions").put("Visibility", "DISABLED"); + assertError("ValidationException", () -> service.updateApplication(invalidVisibility)); + } + @Test void createApplicationRejectsUnsupportedProvidersAndInvalidPortalOptions() { ObjectNode request = mapper.createObjectNode(); @@ -1559,6 +1595,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 6484f6e8d73837a25515078b8016e395145e019b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:57:49 -0400 Subject: [PATCH 79/99] feat(ssoadmin): update instances --- .../test/SsoAdminAccountAssignmentTest.java | 12 +++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 27 ++++-- .../services/ssoadmin/SsoAdminService.java | 86 +++++++++++++++++++ .../ssoadmin/model/InstanceUpdateState.java | 12 +++ .../ssoadmin/SsoAdminIntegrationTest.java | 25 ++++++ .../ssoadmin/SsoAdminServiceTest.java | 58 +++++++++++++ 7 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceUpdateState.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 50ecbcd44b..08867998e7 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -609,6 +609,18 @@ void createInstanceUsesAwsSdk() { assertThat(described.name()).isEqualTo("SdkAccountInstance"); assertThat(described.statusAsString()).isEqualTo("ACTIVE"); assertThat(described.permissionSetsEnabled()).isFalse(); + assertThat(described.encryptionConfigurationDetails().keyTypeAsString()).isEqualTo("AWS_OWNED_KMS_KEY"); + + var renamed = sso.updateInstance(request -> request.instanceArn(instanceArn).name("SdkAccountRenamed")); + assertThat(renamed.sdkHttpResponse().isSuccessful()).isTrue(); + sso.updateInstance(request -> request.instanceArn(instanceArn).permissionSetsEnabled(true)); + var updated = sso.describeInstance(request -> request.instanceArn(instanceArn)); + assertThat(updated.name()).isEqualTo("SdkAccountRenamed"); + assertThat(updated.permissionSetsEnabled()).isTrue(); + assertThat(sso.createInstance(request -> request + .name("SdkAccountInstance") + .clientToken("sdk-create-instance") + .tags(tag -> tag.key("Environment").value("test"))).instanceArn()).isEqualTo(instanceArn); var listed = sso.listInstances(request -> {}).instances(); assertThat(listed).hasSize(1); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index 86706c840d..d43a98aab6 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -12,6 +12,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | --- | --- | | `ListInstances` | Lists the local IAM Identity Center instance. | | `CreateInstance` | Creates a standalone account instance with AWS-compatible singleton, idempotency, tags, and instance metadata. | +| `UpdateInstance` | Updates instance name, encryption configuration, or permission-set availability with current AWS validation while preserving CreateInstance idempotency state. | | `DescribeInstance` | Returns IAM Identity Center instance metadata, status, identity store ID, owner account, creation date, and permission-set availability. | | `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 841d1964b7..8dc6886d3c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -31,6 +31,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, return switch (action) { case "ListInstances" -> listInstances(callerAccountId); case "CreateInstance" -> createInstance(request, callerAccountId, region); + case "UpdateInstance" -> updateInstance(request, callerAccountId); case "DescribeInstance" -> describeInstance(request); case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); @@ -116,8 +117,9 @@ private Response listInstances(String callerAccountId) { ObjectNode node = instances.addObject(); node.put("InstanceArn", instance.instanceArn()); node.put("IdentityStoreId", instance.identityStoreId()); - if (instance.name() != null) { - node.put("Name", instance.name()); + var updateState = service.instanceUpdateState(instance); + if (updateState.name() != null) { + node.put("Name", updateState.name()); } node.put("OwnerAccountId", instance.ownerAccountId()); node.put("CreatedDate", instance.createdDateEpochMillis() / 1000.0d); @@ -143,17 +145,32 @@ private Response createInstance(JsonNode request, String callerAccountId, String return Response.ok(mapper.createObjectNode().put("InstanceArn", instance.instanceArn())).build(); } + private Response updateInstance(JsonNode request, String callerAccountId) { + service.updateInstance(request, callerAccountId); + return Response.ok().build(); + } + private Response describeInstance(JsonNode request) { var instance = service.describeInstance(request); + var updateState = service.instanceUpdateState(instance); ObjectNode response = mapper.createObjectNode(); response.put("CreatedDate", instance.createdDateEpochMillis() / 1000.0d); response.put("IdentityStoreId", instance.identityStoreId()); response.put("InstanceArn", instance.instanceArn()); - if (instance.name() != null) { - response.put("Name", instance.name()); + if (updateState.name() != null) { + response.put("Name", updateState.name()); } response.put("OwnerAccountId", instance.ownerAccountId()); - response.put("PermissionSetsEnabled", !instance.accountInstance()); + response.put("PermissionSetsEnabled", updateState.permissionSetsEnabled()); + ObjectNode encryption = response.putObject("EncryptionConfigurationDetails"); + encryption.put("EncryptionStatus", updateState.encryptionStatus()); + encryption.put("KeyType", updateState.keyType()); + if (updateState.kmsKeyArn() != null) { + encryption.put("KmsKeyArn", updateState.kmsKeyArn()); + } + if (updateState.encryptionStatusReason() != null) { + encryption.put("EncryptionStatusReason", updateState.encryptionStatusReason()); + } response.put("Status", instance.status()); if (instance.statusReason() != null) { response.put("StatusReason", instance.statusReason()); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 2829d3d71f..956d68404e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -21,6 +21,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.AccessControlAttribute; import io.github.hectorvent.floci.services.ssoadmin.model.CustomerManagedPolicyReference; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; +import io.github.hectorvent.floci.services.ssoadmin.model.InstanceUpdateState; import io.github.hectorvent.floci.services.ssoadmin.model.OidcJwtIssuerConfiguration; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionsBoundary; @@ -73,6 +74,7 @@ public class SsoAdminService implements Resettable { private static final Pattern OIDC_CLAIM_ATTRIBUTE_PATH = Pattern.compile("\\p{L}+(?:(\\.|_)\\p{L}+){0,2}"); private static final Pattern OIDC_IDENTITY_STORE_ATTRIBUTE_PATH = Pattern.compile("\\p{L}+(?:\\.\\p{L}+){0,2}"); private static final Pattern OIDC_ISSUER_URL = Pattern.compile("https?://[-a-zA-Z0-9+&@/%=~_|!:,.;]*[-a-zA-Z0-9+&@/%=~_|]"); + private static final Pattern KMS_KEY_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:kms:([a-z]{2,}(-[a-z0-9]+)+):[0-9]{12}:key/(?:mrk-[a-f0-9]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"); private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; @@ -105,6 +107,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend applicationSessionConfigurations; private final StorageBackend> resourceTagOverrides; private final StorageBackend instances; + private final StorageBackend instanceUpdateStates; private final StorageBackend instanceClientTokens; private final StorageBackend instanceDeletionMarkers; private final StorageBackend accessControlAttributeConfigurations; @@ -137,6 +140,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-application-session-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-resource-tag-overrides.json", new TypeReference>>() {}), storageFactory.create("ssoadmin", "ssoadmin-instances.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-instance-update-states.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-client-tokens.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-instance-deletion-markers.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), @@ -166,6 +170,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend applicationSessionConfigurations, StorageBackend> resourceTagOverrides, StorageBackend instances, + StorageBackend instanceUpdateStates, StorageBackend instanceClientTokens, StorageBackend instanceDeletionMarkers, StorageBackend accessControlAttributeConfigurations, @@ -193,6 +198,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.applicationSessionConfigurations = applicationSessionConfigurations; this.resourceTagOverrides = resourceTagOverrides; this.instances = instances; + this.instanceUpdateStates = instanceUpdateStates; this.instanceClientTokens = instanceClientTokens; this.instanceDeletionMarkers = instanceDeletionMarkers; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; @@ -589,6 +595,84 @@ public SsoInstance describeInstance(JsonNode request) { return requireInstance(required(request, "InstanceArn")); } + public synchronized InstanceUpdateState updateInstance(JsonNode request, String callerAccountId) { + validateAccountId(callerAccountId); + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(instanceArn); + if (!callerAccountId.equals(instance.ownerAccountId())) { + throw accessDenied("Only the owning AWS account can update this IAM Identity Center instance."); + } + InstanceUpdateState current = instanceUpdateState(instance); + boolean hasEncryption = request != null && request.has("EncryptionConfiguration") + && !request.get("EncryptionConfiguration").isNull(); + boolean hasPermissionSets = request != null && request.has("PermissionSetsEnabled") + && !request.get("PermissionSetsEnabled").isNull(); + if (hasEncryption && hasPermissionSets) { + throw validation("EncryptionConfiguration and PermissionSetsEnabled cannot be updated in the same request."); + } + + String name = current.name(); + if (request != null && request.has("Name") && !request.get("Name").isNull()) { + name = optionalInstanceName(request); + } + boolean permissionSetsEnabled = current.permissionSetsEnabled(); + String keyType = current.keyType(); + String kmsKeyArn = current.kmsKeyArn(); + if (hasPermissionSets) { + JsonNode enabled = request.get("PermissionSetsEnabled"); + if (!enabled.isBoolean() || !enabled.booleanValue()) { + throw validation("PermissionSetsEnabled only accepts true and cannot be disabled."); + } + permissionSetsEnabled = true; + } + if (hasEncryption) { + JsonNode encryption = request.get("EncryptionConfiguration"); + if (!encryption.isObject()) { + throw validation("EncryptionConfiguration must be an object."); + } + keyType = required(encryption, "KeyType"); + if (!Set.of("AWS_OWNED_KMS_KEY", "CUSTOMER_MANAGED_KEY").contains(keyType)) { + throw validation("EncryptionConfiguration.KeyType is invalid."); + } + JsonNode kmsArnNode = encryption.get("KmsKeyArn"); + kmsKeyArn = kmsArnNode == null || kmsArnNode.isNull() ? null : text(encryption, "KmsKeyArn"); + if ("CUSTOMER_MANAGED_KEY".equals(keyType)) { + if (instance.accountInstance()) { + throw validation("Customer managed KMS keys are supported only for organization instances."); + } + if (kmsKeyArn == null || kmsKeyArn.length() < 20 || kmsKeyArn.length() > 2048 + || !KMS_KEY_ARN.matcher(kmsKeyArn).matches()) { + throw validation("EncryptionConfiguration.KmsKeyArn is required and must be a valid KMS key ARN."); + } + java.util.regex.Matcher kmsArnMatcher = KMS_KEY_ARN.matcher(kmsKeyArn); + if (!kmsArnMatcher.matches() || !instance.primaryRegion().equals(kmsArnMatcher.group(1))) { + throw validation("The customer managed KMS key must be in the IAM Identity Center primary Region."); + } + String kmsAccountId = kmsKeyArn.split(":", 6)[4]; + if (!instance.ownerAccountId().equals(kmsAccountId)) { + throw validation("The customer managed KMS key must be owned by the IAM Identity Center instance account."); + } + String keyId = kmsKeyArn.substring(kmsKeyArn.indexOf(":key/") + 5); + if (!regions.scan(key -> true).isEmpty() && !keyId.startsWith("mrk-")) { + throw validation("A multi-Region IAM Identity Center instance requires a multi-Region KMS key."); + } + } else if (kmsKeyArn != null) { + throw validation("KmsKeyArn cannot be specified with AWS_OWNED_KMS_KEY."); + } else if (!regions.scan(key -> true).isEmpty()) { + throw validation("AWS owned KMS keys are not supported for multi-Region IAM Identity Center instances."); + } + } + InstanceUpdateState updated = new InstanceUpdateState( + name, permissionSetsEnabled, keyType, kmsKeyArn, "ENABLED", null); + instanceUpdateStates.put(instanceArn, updated); + return updated; + } + + public InstanceUpdateState instanceUpdateState(SsoInstance instance) { + return instanceUpdateStates.get(instance.instanceArn()).orElseGet(() -> new InstanceUpdateState( + instance.name(), !instance.accountInstance(), "AWS_OWNED_KMS_KEY", null, "ENABLED", null)); + } + public synchronized void deleteInstance(JsonNode request, String callerAccountId) { validateAccountId(callerAccountId); String instanceArn = required(request, "InstanceArn"); @@ -640,6 +724,7 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId } identityStoreService.deleteIdentityStore(instance.identityStoreId()); instances.delete(callerAccountId); + instanceUpdateStates.delete(instanceArn); resourceTagOverrides.delete(instanceArn); instanceDeletionMarkers.put(callerAccountId, true); } @@ -2190,6 +2275,7 @@ public void clear() { applicationSessionConfigurations.clear(); resourceTagOverrides.clear(); instances.clear(); + instanceUpdateStates.clear(); instanceClientTokens.clear(); instanceDeletionMarkers.clear(); accessControlAttributeConfigurations.clear(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceUpdateState.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceUpdateState.java new file mode 100644 index 0000000000..61067a2796 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/InstanceUpdateState.java @@ -0,0 +1,12 @@ +package io.github.hectorvent.floci.services.ssoadmin.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record InstanceUpdateState( + String name, + boolean permissionSetsEnabled, + String keyType, + String kmsKeyArn, + String encryptionStatus, + String encryptionStatusReason) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index efa3a5c6ad..f673967514 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -50,6 +50,31 @@ void listInstances_isStableAcrossCalls() { org.junit.jupiter.api.Assertions.assertEquals(firstArn, secondArn); } + @Test + void updateInstancePersistsNameAndEncryptionConfiguration() { + String instanceArn = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.UpdateInstance") + .body("{\"InstanceArn\":\"" + instanceArn + "\",\"Name\":\"UpdatedInstance\"," + + "\"EncryptionConfiguration\":{\"KeyType\":\"CUSTOMER_MANAGED_KEY\"," + + "\"KmsKeyArn\":\"arn:aws:kms:us-east-1:000000000000:key/12345678-1234-1234-1234-1234567890ab\"}}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeInstance") + .body("{\"InstanceArn\":\"" + instanceArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("Name", equalTo("UpdatedInstance")) + .body("EncryptionConfigurationDetails.KeyType", equalTo("CUSTOMER_MANAGED_KEY")) + .body("EncryptionConfigurationDetails.EncryptionStatus", equalTo("ENABLED")); + } + @Test void addRegionReturnsAddingAndRejectsDuplicate() { String request = "{\"InstanceArn\":\"arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e\",\"RegionName\":\"eu-west-2\"}"; diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 0eca4405d1..6d31194892 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -12,6 +12,7 @@ import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAuthenticationMethod; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationGrant; import io.github.hectorvent.floci.services.ssoadmin.model.InstanceAccessControlAttributeConfiguration; +import io.github.hectorvent.floci.services.ssoadmin.model.InstanceUpdateState; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioning; import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSetProvisioningOperation; @@ -71,6 +72,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage>(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), @@ -294,12 +296,67 @@ void createInstancePersistsMetadataAndSupportsIdempotentReplay() { assertEquals(created.instanceArn(), emptyService.createInstance(request, ACCOUNT_ID, "us-west-2").instanceArn()); assertEquals(1, emptyService.listInstances(ACCOUNT_ID).size()); + ObjectNode rename = mapper.createObjectNode(); + rename.put("InstanceArn", created.instanceArn()); + rename.put("Name", "RenamedInstance"); + InstanceUpdateState renamed = emptyService.updateInstance(rename, ACCOUNT_ID); + assertEquals("RenamedInstance", renamed.name()); + assertFalse(renamed.permissionSetsEnabled()); + + ObjectNode enablePermissionSets = mapper.createObjectNode(); + enablePermissionSets.put("InstanceArn", created.instanceArn()); + enablePermissionSets.put("PermissionSetsEnabled", true); + assertTrue(emptyService.updateInstance(enablePermissionSets, ACCOUNT_ID).permissionSetsEnabled()); + assertEquals(created.instanceArn(), emptyService.createInstance(request, ACCOUNT_ID, "us-west-2").instanceArn()); + + ObjectNode disablePermissionSets = enablePermissionSets.deepCopy().put("PermissionSetsEnabled", false); + assertError("ValidationException", () -> emptyService.updateInstance(disablePermissionSets, ACCOUNT_ID)); + ObjectNode mismatch = request.deepCopy(); mismatch.put("Name", "DifferentName"); assertError("IdempotentParameterMismatch", () -> emptyService.createInstance(mismatch, ACCOUNT_ID, "us-west-2")); } + @Test + void updateInstanceValidatesEncryptionConfiguration() { + ObjectNode customerManaged = mapper.createObjectNode(); + customerManaged.put("InstanceArn", service.getInstanceArn()); + customerManaged.putObject("EncryptionConfiguration") + .put("KeyType", "CUSTOMER_MANAGED_KEY") + .put("KmsKeyArn", "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-1234567890ab"); + InstanceUpdateState encrypted = service.updateInstance(customerManaged, ACCOUNT_ID); + assertEquals("CUSTOMER_MANAGED_KEY", encrypted.keyType()); + assertEquals("ENABLED", encrypted.encryptionStatus()); + + ObjectNode both = customerManaged.deepCopy().put("PermissionSetsEnabled", true); + assertError("ValidationException", () -> service.updateInstance(both, ACCOUNT_ID)); + + ObjectNode missingKmsArn = mapper.createObjectNode(); + missingKmsArn.put("InstanceArn", service.getInstanceArn()); + missingKmsArn.putObject("EncryptionConfiguration").put("KeyType", "CUSTOMER_MANAGED_KEY"); + assertError("ValidationException", () -> service.updateInstance(missingKmsArn, ACCOUNT_ID)); + + ObjectNode wrongRegion = customerManaged.deepCopy(); + wrongRegion.withObject("EncryptionConfiguration") + .put("KmsKeyArn", "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-1234567890ab"); + assertError("ValidationException", () -> service.updateInstance(wrongRegion, ACCOUNT_ID)); + + ObjectNode wrongAccount = customerManaged.deepCopy(); + wrongAccount.withObject("EncryptionConfiguration") + .put("KmsKeyArn", "arn:aws:kms:us-east-1:999999999999:key/12345678-1234-1234-1234-1234567890ab"); + assertError("ValidationException", () -> service.updateInstance(wrongAccount, ACCOUNT_ID)); + + assertError("AccessDeniedException", () -> service.updateInstance(customerManaged, "999999999999")); + + ObjectNode awsOwnedWithArn = mapper.createObjectNode(); + awsOwnedWithArn.put("InstanceArn", service.getInstanceArn()); + awsOwnedWithArn.putObject("EncryptionConfiguration") + .put("KeyType", "AWS_OWNED_KMS_KEY") + .put("KmsKeyArn", "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-1234567890ab"); + assertError("ValidationException", () -> service.updateInstance(awsOwnedWithArn, ACCOUNT_ID)); + } + @Test void createInstanceEnforcesSingletonAndValidatesInputs() { ObjectNode duplicate = mapper.createObjectNode(); @@ -1605,6 +1662,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage>(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), From 4099b3af84faad87e976df33cdf4a345a0048c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 20:59:57 -0400 Subject: [PATCH 80/99] feat(ssoadmin): update ABAC configuration --- .../test/SsoAdminAccountAssignmentTest.java | 11 +++ docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 85 +++++++++++-------- .../ssoadmin/SsoAdminIntegrationTest.java | 33 +++++++ .../ssoadmin/SsoAdminServiceTest.java | 32 +++++++ 6 files changed, 134 insertions(+), 34 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 08867998e7..9bbac6f596 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -576,6 +576,17 @@ void createInstanceAccessControlAttributeConfigurationUsesAwsSdk() { .value(value -> value.source("${path:enterprise.department}")))))) .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + var updateResponse = sso.updateInstanceAccessControlAttributeConfiguration(request -> request + .instanceArn(instanceArn) + .instanceAccessControlAttributeConfiguration(configuration -> configuration + .accessControlAttributes(attribute -> attribute + .key("CostCenter") + .value(value -> value.source("${path:enterprise.costCenter}"))))); + assertThat(updateResponse.sdkHttpResponse().isSuccessful()).isTrue(); + assertThat(sso.describeInstanceAccessControlAttributeConfiguration(request -> request.instanceArn(instanceArn)) + .instanceAccessControlAttributeConfiguration().accessControlAttributes()) + .singleElement().satisfies(attribute -> assertThat(attribute.key()).isEqualTo("CostCenter")); + var deleteResponse = sso.deleteInstanceAccessControlAttributeConfiguration(request -> request .instanceArn(instanceArn)); assertThat(deleteResponse.sdkHttpResponse().isSuccessful()).isTrue(); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index d43a98aab6..abfb290d5b 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -17,6 +17,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `DeleteInstance` | Deletes an owned IAM Identity Center instance with AWS-compatible empty response semantics and local dependent-resource cleanup. | | `CreateInstanceAccessControlAttributeConfiguration` | Enables ABAC and stores up to 50 IAM Identity Center access control attributes. | | `DescribeInstanceAccessControlAttributeConfiguration` | Returns the IAM Identity Center ABAC attribute configuration and its creation status for an instance. | +| `UpdateInstanceAccessControlAttributeConfiguration` | Replaces the ABAC access-control attribute mappings for an enabled IAM Identity Center configuration, including an empty 0-attribute mapping set. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | | `DescribeTrustedTokenIssuer` | Returns the trusted token issuer name, ARN, OIDC JWT configuration, and issuer type. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 8dc6886d3c..15bc6260f8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -36,6 +36,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "DeleteInstance" -> deleteInstance(request, callerAccountId); case "CreateInstanceAccessControlAttributeConfiguration" -> createInstanceAccessControlAttributeConfiguration(request); case "DescribeInstanceAccessControlAttributeConfiguration" -> describeInstanceAccessControlAttributeConfiguration(request); + case "UpdateInstanceAccessControlAttributeConfiguration" -> updateInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); case "DescribeTrustedTokenIssuer" -> describeTrustedTokenIssuer(request); @@ -205,6 +206,11 @@ private Response describeInstanceAccessControlAttributeConfiguration(JsonNode re return Response.ok(response).build(); } + private Response updateInstanceAccessControlAttributeConfiguration(JsonNode request) { + service.updateInstanceAccessControlAttributeConfiguration(request); + return Response.ok().build(); + } + private Response deleteInstanceAccessControlAttributeConfiguration(JsonNode request) { service.deleteInstanceAccessControlAttributeConfiguration(request); return Response.ok().build(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 956d68404e..0dc7258144 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -488,46 +488,25 @@ public synchronized InstanceAccessControlAttributeConfiguration createInstanceAc if (accessControlAttributeConfigurations.get(instanceArn).isPresent()) { throw conflict("An instance access control attribute configuration already exists for this IAM Identity Center instance."); } - JsonNode configurationNode = request == null ? null : request.get("InstanceAccessControlAttributeConfiguration"); - if (configurationNode == null || !configurationNode.isObject()) { - throw validation("InstanceAccessControlAttributeConfiguration must be an object."); - } - JsonNode attributesNode = configurationNode.get("AccessControlAttributes"); - if (attributesNode == null || !attributesNode.isArray()) { - throw validation("AccessControlAttributes must be an array."); - } - if (attributesNode.size() > 50) { - throw validation("AccessControlAttributes can contain at most 50 attributes."); - } - List attributes = new ArrayList<>(); - for (JsonNode attributeNode : attributesNode) { - if (!attributeNode.isObject()) { - throw validation("Each access control attribute must be an object."); - } - String key = required(attributeNode, "Key"); - if (key.length() > 128 || !ACCESS_CONTROL_ATTRIBUTE_KEY.matcher(key).matches()) { - throw validation("Access control attribute Key is invalid."); - } - JsonNode valueNode = attributeNode.get("Value"); - if (valueNode == null || !valueNode.isObject()) { - throw validation("Access control attribute Value must be an object."); - } - JsonNode sourceNode = valueNode.get("Source"); - if (sourceNode == null || !sourceNode.isArray() || sourceNode.size() != 1 || !sourceNode.get(0).isTextual()) { - throw validation("Access control attribute Source must contain exactly one string."); - } - String source = sourceNode.get(0).textValue(); - if (source.length() > 256 || !ACCESS_CONTROL_ATTRIBUTE_SOURCE.matcher(source).matches()) { - throw validation("Access control attribute Source is invalid."); - } - attributes.add(new AccessControlAttribute(key, source)); - } + List attributes = parseAccessControlAttributes(request); InstanceAccessControlAttributeConfiguration configuration = new InstanceAccessControlAttributeConfiguration( instanceArn, attributes, "ENABLED", null); accessControlAttributeConfigurations.put(instanceArn, configuration); return configuration; } + public synchronized InstanceAccessControlAttributeConfiguration updateInstanceAccessControlAttributeConfiguration(JsonNode request) { + String instanceArn = required(request, "InstanceArn"); + requireInstance(instanceArn); + if (accessControlAttributeConfigurations.get(instanceArn).isEmpty()) { + throw notFound("Instance access control attribute configuration not found for: " + instanceArn); + } + InstanceAccessControlAttributeConfiguration configuration = new InstanceAccessControlAttributeConfiguration( + instanceArn, parseAccessControlAttributes(request), "ENABLED", null); + accessControlAttributeConfigurations.put(instanceArn, configuration); + return configuration; + } + public InstanceAccessControlAttributeConfiguration describeInstanceAccessControlAttributeConfiguration(JsonNode request) { return getInstanceAccessControlAttributeConfiguration(required(request, "InstanceArn")); } @@ -2064,6 +2043,44 @@ private static boolean applicationMatches(SsoApplication application, String pro && java.util.Objects.equals(application.tags(), tags); } + private static List parseAccessControlAttributes(JsonNode request) { + JsonNode configurationNode = request == null ? null : request.get("InstanceAccessControlAttributeConfiguration"); + if (configurationNode == null || !configurationNode.isObject()) { + throw validation("InstanceAccessControlAttributeConfiguration must be an object."); + } + JsonNode attributesNode = configurationNode.get("AccessControlAttributes"); + if (attributesNode == null || !attributesNode.isArray()) { + throw validation("AccessControlAttributes must be an array."); + } + if (attributesNode.size() > 50) { + throw validation("AccessControlAttributes can contain at most 50 attributes."); + } + List attributes = new ArrayList<>(); + for (JsonNode attributeNode : attributesNode) { + if (!attributeNode.isObject()) { + throw validation("Each access control attribute must be an object."); + } + String key = required(attributeNode, "Key"); + if (key.length() > 128 || !ACCESS_CONTROL_ATTRIBUTE_KEY.matcher(key).matches()) { + throw validation("Access control attribute Key is invalid."); + } + JsonNode valueNode = attributeNode.get("Value"); + if (valueNode == null || !valueNode.isObject()) { + throw validation("Access control attribute Value must be an object."); + } + JsonNode sourceNode = valueNode.get("Source"); + if (sourceNode == null || !sourceNode.isArray() || sourceNode.size() != 1 || !sourceNode.get(0).isTextual()) { + throw validation("Access control attribute Source must contain exactly one string."); + } + String source = sourceNode.get(0).textValue(); + if (source.length() > 256 || !ACCESS_CONTROL_ATTRIBUTE_SOURCE.matcher(source).matches()) { + throw validation("Access control attribute Source is invalid."); + } + attributes.add(new AccessControlAttribute(key, source)); + } + return attributes; + } + private static ApplicationPortalOptions parseUpdatePortalOptions(JsonNode node, ApplicationPortalOptions current) { if (!node.isObject()) { throw validation("PortalOptions must be an object."); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index f673967514..ebcb650ffd 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -50,6 +50,39 @@ void listInstances_isStableAcrossCalls() { org.junit.jupiter.api.Assertions.assertEquals(firstArn, secondArn); } + @Test + void updateInstanceAccessControlAttributeConfigurationReplacesAbacMappings() { + String instanceArn = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; + String createBody = "{\"InstanceArn\":\"" + instanceArn + "\"," + + "\"InstanceAccessControlAttributeConfiguration\":{\"AccessControlAttributes\":[]}}"; + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateInstanceAccessControlAttributeConfiguration") + .body(createBody) + .when().post("/") + .then().statusCode(200); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.UpdateInstanceAccessControlAttributeConfiguration") + .body("{\"InstanceArn\":\"" + instanceArn + "\"," + + "\"InstanceAccessControlAttributeConfiguration\":{\"AccessControlAttributes\":[{" + + "\"Key\":\"Department\",\"Value\":{\"Source\":[\"${path:enterprise.department}\"]}}]}}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeInstanceAccessControlAttributeConfiguration") + .body("{\"InstanceArn\":\"" + instanceArn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("InstanceAccessControlAttributeConfiguration.AccessControlAttributes[0].Key", equalTo("Department")); + } + @Test void updateInstancePersistsNameAndEncryptionConfiguration() { String instanceArn = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 6d31194892..d2ca9fc9b4 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -202,6 +202,38 @@ void createInstanceAccessControlAttributeConfigurationRejectsInvalidAttributes() () -> service.createInstanceAccessControlAttributeConfiguration(invalidSourceCount)); } + @Test + void updateInstanceAccessControlAttributeConfigurationReplacesMappings() { + ObjectNode create = mapper.createObjectNode(); + create.put("InstanceArn", service.getInstanceArn()); + create.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes") + .addObject().put("Key", "Department").putObject("Value").putArray("Source") + .add("${path:enterprise.department}"); + service.createInstanceAccessControlAttributeConfiguration(create); + + ObjectNode update = mapper.createObjectNode(); + update.put("InstanceArn", service.getInstanceArn()); + update.putObject("InstanceAccessControlAttributeConfiguration") + .putArray("AccessControlAttributes") + .addObject().put("Key", "CostCenter").putObject("Value").putArray("Source") + .add("${path:enterprise.costCenter}"); + InstanceAccessControlAttributeConfiguration updated = + service.updateInstanceAccessControlAttributeConfiguration(update); + assertEquals(1, updated.accessControlAttributes().size()); + assertEquals("CostCenter", updated.accessControlAttributes().get(0).key()); + assertEquals("ENABLED", updated.status()); + + ObjectNode empty = mapper.createObjectNode(); + empty.put("InstanceArn", service.getInstanceArn()); + empty.putObject("InstanceAccessControlAttributeConfiguration").putArray("AccessControlAttributes"); + assertTrue(service.updateInstanceAccessControlAttributeConfiguration(empty).accessControlAttributes().isEmpty()); + + service.deleteInstanceAccessControlAttributeConfiguration(mapper.createObjectNode().put("InstanceArn", service.getInstanceArn())); + assertError("ResourceNotFoundException", + () -> service.updateInstanceAccessControlAttributeConfiguration(update)); + } + @Test void deleteInstanceAccessControlAttributeConfigurationRemovesAbacConfiguration() { ObjectNode create = mapper.createObjectNode(); From 0023e6e1caf217273d2b578f87f630448bce4e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 21:03:27 -0400 Subject: [PATCH 81/99] feat(ssoadmin): update trusted token issuers --- .../test/SsoAdminAccountAssignmentTest.java | 10 ++- docs/services/ssoadmin.md | 1 + .../ssoadmin/SsoAdminJsonHandler.java | 6 ++ .../services/ssoadmin/SsoAdminService.java | 68 ++++++++++++++++++- .../ssoadmin/SsoAdminIntegrationTest.java | 30 +++++++- .../ssoadmin/SsoAdminServiceTest.java | 24 +++++++ 6 files changed, 136 insertions(+), 3 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 9bbac6f596..f926fe9aac 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -521,9 +521,17 @@ void createTrustedTokenIssuerUsesAwsSdk() { .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); assertThat(replay.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); + var updateResponse = sso.updateTrustedTokenIssuer(request -> request + .trustedTokenIssuerArn(created.trustedTokenIssuerArn()) + .name("SdkIssuerUpdated") + .trustedTokenIssuerConfiguration(configuration -> configuration + .oidcJwtConfiguration(oidc -> oidc.claimAttributePath("email") + .identityStoreAttributePath("emails.value") + .jwksRetrievalOption("OPEN_ID_DISCOVERY")))); + assertThat(updateResponse.sdkHttpResponse().isSuccessful()).isTrue(); var described = sso.describeTrustedTokenIssuer(request -> request .trustedTokenIssuerArn(created.trustedTokenIssuerArn())); - assertThat(described.name()).isEqualTo("SdkIssuer"); + assertThat(described.name()).isEqualTo("SdkIssuerUpdated"); assertThat(described.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); assertThat(described.trustedTokenIssuerConfiguration().oidcJwtConfiguration().issuerUrl()) .isEqualTo("https://issuer.example.com"); diff --git a/docs/services/ssoadmin.md b/docs/services/ssoadmin.md index abfb290d5b..9e524a5dd1 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -20,6 +20,7 @@ Floci supports the SSO Admin operations used to manage IAM Identity Center permi | `UpdateInstanceAccessControlAttributeConfiguration` | Replaces the ABAC access-control attribute mappings for an enabled IAM Identity Center configuration, including an empty 0-attribute mapping set. | | `DeleteInstanceAccessControlAttributeConfiguration` | Disables ABAC for an IAM Identity Center instance and removes its access control attribute configuration. | | `CreateTrustedTokenIssuer` | Creates an OIDC JWT trusted token issuer with idempotency, tags, and AWS-compatible validation. | +| `UpdateTrustedTokenIssuer` | Updates the trusted token issuer name and mutable OIDC JWT attribute-mapping settings while preserving the issuer URL and Create idempotency state. | | `DescribeTrustedTokenIssuer` | Returns the trusted token issuer name, ARN, OIDC JWT configuration, and issuer type. | | `ListTrustedTokenIssuers` | Lists trusted token issuer metadata for an IAM Identity Center instance with AWS-compatible pagination. | | `DeleteTrustedTokenIssuer` | Deletes a trusted token issuer, validates its AWS ARN, and clears local idempotency mappings. | diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index 15bc6260f8..cf89ed8bcd 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -39,6 +39,7 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "UpdateInstanceAccessControlAttributeConfiguration" -> updateInstanceAccessControlAttributeConfiguration(request); case "DeleteInstanceAccessControlAttributeConfiguration" -> deleteInstanceAccessControlAttributeConfiguration(request); case "CreateTrustedTokenIssuer" -> createTrustedTokenIssuer(request, callerAccountId); + case "UpdateTrustedTokenIssuer" -> updateTrustedTokenIssuer(request); case "DescribeTrustedTokenIssuer" -> describeTrustedTokenIssuer(request); case "ListTrustedTokenIssuers" -> listTrustedTokenIssuers(request); case "DeleteTrustedTokenIssuer" -> deleteTrustedTokenIssuer(request); @@ -222,6 +223,11 @@ private Response createTrustedTokenIssuer(JsonNode request, String callerAccount .put("TrustedTokenIssuerArn", issuer.trustedTokenIssuerArn())).build(); } + private Response updateTrustedTokenIssuer(JsonNode request) { + service.updateTrustedTokenIssuer(request); + return Response.ok().build(); + } + private Response describeTrustedTokenIssuer(JsonNode request) { var issuer = service.describeTrustedTokenIssuer(request); ObjectNode response = trustedTokenIssuerMetadataNode(issuer); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 0dc7258144..331733601d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -112,6 +112,7 @@ public class SsoAdminService implements Resettable { private final StorageBackend instanceDeletionMarkers; private final StorageBackend accessControlAttributeConfigurations; private final StorageBackend trustedTokenIssuers; + private final StorageBackend trustedTokenIssuerUpdateOverrides; private final StorageBackend trustedTokenIssuerClientTokens; private final IdentityStoreService identityStoreService; private final OrganizationsService organizationsService; @@ -145,6 +146,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident storageFactory.create("ssoadmin", "ssoadmin-instance-deletion-markers.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-access-control-attribute-configurations.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuers.json", new TypeReference>() {}), + storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuer-update-overrides.json", new TypeReference>() {}), storageFactory.create("ssoadmin", "ssoadmin-trusted-token-issuer-client-tokens.json", new TypeReference>() {}), identityStoreService, organizationsService, @@ -175,6 +177,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident StorageBackend instanceDeletionMarkers, StorageBackend accessControlAttributeConfigurations, StorageBackend trustedTokenIssuers, + StorageBackend trustedTokenIssuerUpdateOverrides, StorageBackend trustedTokenIssuerClientTokens, IdentityStoreService identityStoreService, OrganizationsService organizationsService, @@ -203,6 +206,7 @@ public SsoAdminService(StorageFactory storageFactory, IdentityStoreService ident this.instanceDeletionMarkers = instanceDeletionMarkers; this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; this.trustedTokenIssuers = trustedTokenIssuers; + this.trustedTokenIssuerUpdateOverrides = trustedTokenIssuerUpdateOverrides; this.trustedTokenIssuerClientTokens = trustedTokenIssuerClientTokens; this.identityStoreService = identityStoreService; this.organizationsService = organizationsService; @@ -349,6 +353,7 @@ public PaginatedResult listTrustedTokenIssuers(JsonNode requ String instanceArn = required(request, "InstanceArn"); requireInstance(instanceArn); List matching = trustedTokenIssuers.scan(key -> true).stream() + .map(issuer -> trustedTokenIssuerUpdateOverrides.get(issuer.trustedTokenIssuerArn()).orElse(issuer)) .filter(issuer -> instanceArn.equals(issuer.instanceArn())) .sorted(Comparator.comparing(TrustedTokenIssuer::trustedTokenIssuerArn)) .toList(); @@ -450,14 +455,73 @@ private record TaggableResource(Map tags, String instanceArn) {} public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { validateTrustedTokenIssuerArn(trustedTokenIssuerArn); - return trustedTokenIssuers.get(trustedTokenIssuerArn) + TrustedTokenIssuer base = trustedTokenIssuers.get(trustedTokenIssuerArn) .orElseThrow(() -> notFound("Trusted token issuer not found: " + trustedTokenIssuerArn)); + return trustedTokenIssuerUpdateOverrides.get(trustedTokenIssuerArn).orElse(base); + } + + public synchronized TrustedTokenIssuer updateTrustedTokenIssuer(JsonNode request) { + String trustedTokenIssuerArn = required(request, "TrustedTokenIssuerArn"); + TrustedTokenIssuer current = getTrustedTokenIssuer(trustedTokenIssuerArn); + String name = current.name(); + if (request != null && request.has("Name") && !request.get("Name").isNull()) { + name = text(request, "Name"); + if (name == null || name.isEmpty() || name.length() > 255 || !INSTANCE_NAME.matcher(name).matches()) { + throw validation("Name must be 1-255 characters and match [\\w+=,.@-]+."); + } + } + OidcJwtIssuerConfiguration oidc = current.oidcJwtConfiguration(); + if (request != null && request.has("TrustedTokenIssuerConfiguration") + && !request.get("TrustedTokenIssuerConfiguration").isNull()) { + JsonNode configuration = request.get("TrustedTokenIssuerConfiguration"); + if (!configuration.isObject() || configuration.size() != 1 + || !configuration.has("OidcJwtConfiguration") + || !configuration.get("OidcJwtConfiguration").isObject()) { + throw validation("TrustedTokenIssuerConfiguration must contain exactly one OidcJwtConfiguration."); + } + JsonNode update = configuration.get("OidcJwtConfiguration"); + if (update.has("IssuerUrl")) { + throw validation("IssuerUrl cannot be updated."); + } + String claimAttributePath = oidc.claimAttributePath(); + if (update.has("ClaimAttributePath") && !update.get("ClaimAttributePath").isNull()) { + claimAttributePath = text(update, "ClaimAttributePath"); + if (claimAttributePath == null || claimAttributePath.isEmpty() || claimAttributePath.length() > 255 + || !OIDC_CLAIM_ATTRIBUTE_PATH.matcher(claimAttributePath).matches()) { + throw validation("ClaimAttributePath is invalid."); + } + } + String identityStoreAttributePath = oidc.identityStoreAttributePath(); + if (update.has("IdentityStoreAttributePath") && !update.get("IdentityStoreAttributePath").isNull()) { + identityStoreAttributePath = text(update, "IdentityStoreAttributePath"); + if (identityStoreAttributePath == null || identityStoreAttributePath.isEmpty() + || identityStoreAttributePath.length() > 255 + || !OIDC_IDENTITY_STORE_ATTRIBUTE_PATH.matcher(identityStoreAttributePath).matches()) { + throw validation("IdentityStoreAttributePath is invalid."); + } + } + String jwksRetrievalOption = oidc.jwksRetrievalOption(); + if (update.has("JwksRetrievalOption") && !update.get("JwksRetrievalOption").isNull()) { + jwksRetrievalOption = text(update, "JwksRetrievalOption"); + if (!"OPEN_ID_DISCOVERY".equals(jwksRetrievalOption)) { + throw validation("JwksRetrievalOption must be OPEN_ID_DISCOVERY."); + } + } + oidc = new OidcJwtIssuerConfiguration(claimAttributePath, identityStoreAttributePath, + oidc.issuerUrl(), jwksRetrievalOption); + } + TrustedTokenIssuer updated = new TrustedTokenIssuer( + current.trustedTokenIssuerArn(), current.instanceArn(), name, current.trustedTokenIssuerType(), + oidc, current.tags()); + trustedTokenIssuerUpdateOverrides.put(trustedTokenIssuerArn, updated); + return updated; } public synchronized void deleteTrustedTokenIssuer(JsonNode request) { String trustedTokenIssuerArn = required(request, "TrustedTokenIssuerArn"); getTrustedTokenIssuer(trustedTokenIssuerArn); trustedTokenIssuers.delete(trustedTokenIssuerArn); + trustedTokenIssuerUpdateOverrides.delete(trustedTokenIssuerArn); resourceTagOverrides.delete(trustedTokenIssuerArn); for (String key : new ArrayList<>(trustedTokenIssuerClientTokens.keys())) { if (trustedTokenIssuerArn.equals(trustedTokenIssuerClientTokens.get(key).orElse(null))) { @@ -687,6 +751,7 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId TrustedTokenIssuer issuer = trustedTokenIssuers.get(key).orElse(null); if (issuer != null && instanceArn.equals(issuer.instanceArn())) { trustedTokenIssuers.delete(key); + trustedTokenIssuerUpdateOverrides.delete(key); resourceTagOverrides.delete(key); } } @@ -2297,6 +2362,7 @@ public void clear() { instanceDeletionMarkers.clear(); accessControlAttributeConfigurations.clear(); trustedTokenIssuers.clear(); + trustedTokenIssuerUpdateOverrides.clear(); trustedTokenIssuerClientTokens.clear(); ensureBootstrapInstance(defaultAccountId, defaultRegion); } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java index ebcb650ffd..a0b07818d0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminIntegrationTest.java @@ -980,6 +980,34 @@ void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IssuerUrl", equalTo("https://issuer.example.com")) .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.JwksRetrievalOption", equalTo("OPEN_ID_DISCOVERY")); + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.UpdateTrustedTokenIssuer") + .body("{\"TrustedTokenIssuerArn\":\"" + arn + "\",\"Name\":\"IntegrationIssuerUpdated\"," + + "\"TrustedTokenIssuerConfiguration\":{\"OidcJwtConfiguration\":{\"ClaimAttributePath\":\"email\"}}}") + .when().post("/") + .then().statusCode(200).body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.DescribeTrustedTokenIssuer") + .body("{\"TrustedTokenIssuerArn\":\"" + arn + "\"}") + .when().post("/") + .then().statusCode(200) + .body("Name", equalTo("IntegrationIssuerUpdated")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.ClaimAttributePath", equalTo("email")) + .body("TrustedTokenIssuerConfiguration.OidcJwtConfiguration.IssuerUrl", equalTo("https://issuer.example.com")); + + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.CreateTrustedTokenIssuer") + .body(request) + .when().post("/") + .then().statusCode(200).body("TrustedTokenIssuerArn", equalTo(arn)); + given() .contentType("application/x-amz-json-1.1") .header("Authorization", AUTH_HEADER) @@ -989,7 +1017,7 @@ void createTrustedTokenIssuerReturnsAwsArnAndSupportsIdempotency() { .then() .statusCode(200) .body("TrustedTokenIssuers.TrustedTokenIssuerArn", org.hamcrest.Matchers.hasItem(arn)) - .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.Name", equalTo("IntegrationIssuer")) + .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.Name", equalTo("IntegrationIssuerUpdated")) .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.TrustedTokenIssuerType", equalTo("OIDC_JWT")); } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index d2ca9fc9b4..44caf4b181 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -77,6 +77,7 @@ void setUp() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), identityStoreService, organizationsService, @@ -108,6 +109,28 @@ void createTrustedTokenIssuerPersistsOidcConfigurationAndSupportsIdempotency() { () -> service.createTrustedTokenIssuer(mismatch, ACCOUNT_ID)); } + @Test + void updateTrustedTokenIssuerChangesMutableFieldsAndPreservesCreateIdempotency() { + ObjectNode create = trustedTokenIssuerRequest("OriginalIssuer", "update-tti-token"); + TrustedTokenIssuer created = service.createTrustedTokenIssuer(create, ACCOUNT_ID); + ObjectNode update = mapper.createObjectNode(); + update.put("TrustedTokenIssuerArn", created.trustedTokenIssuerArn()); + update.put("Name", "UpdatedIssuer"); + update.putObject("TrustedTokenIssuerConfiguration").putObject("OidcJwtConfiguration") + .put("ClaimAttributePath", "email") + .put("IdentityStoreAttributePath", "emails.value") + .put("JwksRetrievalOption", "OPEN_ID_DISCOVERY"); + TrustedTokenIssuer updated = service.updateTrustedTokenIssuer(update); + assertEquals("UpdatedIssuer", updated.name()); + assertEquals("email", updated.oidcJwtConfiguration().claimAttributePath()); + assertEquals(created.oidcJwtConfiguration().issuerUrl(), updated.oidcJwtConfiguration().issuerUrl()); + assertEquals(created.trustedTokenIssuerArn(), service.createTrustedTokenIssuer(create, ACCOUNT_ID).trustedTokenIssuerArn()); + ObjectNode invalid = update.deepCopy(); + invalid.withObject("TrustedTokenIssuerConfiguration").withObject("OidcJwtConfiguration") + .put("IssuerUrl", "https://other.example.com"); + assertError("ValidationException", () -> service.updateTrustedTokenIssuer(invalid)); + } + @Test void deleteTrustedTokenIssuerRemovesIssuerAndIdempotencyToken() { ObjectNode create = trustedTokenIssuerRequest("DeleteIssuer", "delete-tti-token"); @@ -1699,6 +1722,7 @@ private SsoAdminService emptyService() { new InMemoryStorage(), new InMemoryStorage(), new InMemoryStorage(), + new InMemoryStorage(), new InMemoryStorage(), identityStoreService, organizationsService, From 9e0145f680189fbf136ea7b4d6d09e07e60a95a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 21:37:55 -0400 Subject: [PATCH 82/99] feat(ssooidc): register clients --- .../java/com/floci/test/TestFixtures.java | 8 + .../test/java/com/floci/test/SsoOidcTest.java | 34 ++++ docs/services/index.md | 1 + docs/services/ssooidc.md | 27 +++ .../floci/config/EmulatorConfig.java | 6 + .../core/common/ResolvedServiceCatalog.java | 5 + .../services/ssooidc/SsoOidcController.java | 67 +++++++ .../services/ssooidc/SsoOidcException.java | 20 +++ .../services/ssooidc/SsoOidcService.java | 164 ++++++++++++++++++ .../ssooidc/model/RegisteredClient.java | 23 +++ .../ssooidc/SsoOidcIntegrationTest.java | 41 +++++ .../services/ssooidc/SsoOidcServiceTest.java | 83 +++++++++ tools/docs/service_matrix.yaml | 1 + 13 files changed, 480 insertions(+) create mode 100644 compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java create mode 100644 docs/services/ssooidc.md create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcException.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index e8040a4d43..06aea66d7a 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java @@ -34,6 +34,7 @@ import software.amazon.awssdk.services.fis.FisClient; import software.amazon.awssdk.services.organizations.OrganizationsClient; import software.amazon.awssdk.services.ssoadmin.SsoAdminClient; +import software.amazon.awssdk.services.ssooidc.SsoOidcClient; import software.amazon.awssdk.services.identitystore.IdentitystoreClient; import software.amazon.awssdk.services.budgets.BudgetsClient; import software.amazon.awssdk.services.macie2.Macie2Client; @@ -325,6 +326,13 @@ public static SsoAdminClient ssoAdminClient(String accountId) { .build(); } + public static SsoOidcClient ssoOidcClient() { + return SsoOidcClient.builder() + .endpointOverride(ENDPOINT) + .region(REGION) + .build(); + } + public static IdentitystoreClient identityStoreClient() { return IdentitystoreClient.builder() .endpointOverride(ENDPOINT) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java new file mode 100644 index 0000000000..3679b4b5c5 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -0,0 +1,34 @@ +package com.floci.test; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.ssooidc.SsoOidcClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +@DisplayName("IAM Identity Center OIDC") +class SsoOidcTest { + + @Test + @DisplayName("registers a public OIDC client through the AWS SDK") + void registerClientUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only client registration"); + + try (SsoOidcClient oidc = TestFixtures.ssoOidcClient()) { + var response = oidc.registerClient(request -> request + .clientName("Floci SDK CLI") + .clientType("public") + .grantTypes("authorization_code", "refresh_token") + .redirectUris("http://127.0.0.1:8400/callback") + .scopes("sso:account:access")); + + assertThat(response.clientId()).matches("[0-9a-f]{32}"); + assertThat(response.clientSecret()).matches("[0-9a-f]{64}"); + assertThat(response.clientIdIssuedAt()).isPositive(); + assertThat(response.clientSecretExpiresAt()).isGreaterThan(response.clientIdIssuedAt()); + assertThat(response.authorizationEndpoint()).endsWith("/authorize"); + assertThat(response.tokenEndpoint()).endsWith("/token"); + } + } +} diff --git a/docs/services/index.md b/docs/services/index.md index b5d2e020d2..d533edebb1 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -47,6 +47,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [AWS Account Management](account.md) | `/putAlternateContact`, `/getAlternateContact` | REST JSON | 2 | | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | +| [IAM Identity Center OIDC](ssooidc.md) | `/client/register` | REST JSON | 1 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssooidc.md b/docs/services/ssooidc.md new file mode 100644 index 0000000000..39492a6c46 --- /dev/null +++ b/docs/services/ssooidc.md @@ -0,0 +1,27 @@ +# AWS IAM Identity Center OIDC + +**Protocol:** REST JSON + +**Endpoint:** `http://localhost:4566` + +Floci emulates the IAM Identity Center OIDC registration and token endpoints used by public OAuth 2.0 clients. + +## Supported Actions + + +| Action | Description | +| --- | --- | +| `RegisterClient` | Registers a public OIDC client, persists its generated client credentials, and returns local authorization and token endpoints. | + + +`RegisterClient` supports the authorization-code, device-code, and refresh-token grant identifiers documented by AWS. Client registrations are persisted so later device authorization and token operations can authenticate the generated client ID and secret. + +OIDC failures use the AWS response shape with `error` and `error_description`. The local emulator assigns a 90-day client-secret lifetime; AWS documents the expiration timestamp but does not publish a fixed lifetime for this operation. + +See the [IAM Identity Center OIDC API Reference](https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html). + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `FLOCI_SERVICES_SSOOIDC_ENABLED` | `true` | Enable or disable IAM Identity Center OIDC | diff --git a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java index 926d7613d6..e5525e6061 100644 --- a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java +++ b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java @@ -724,6 +724,7 @@ interface ServicesConfig { NetworkFirewallServiceConfig networkfirewall(); ServiceCatalogServiceConfig servicecatalog(); SsoAdminServiceConfig ssoadmin(); + SsoOidcServiceConfig ssooidc(); Macie2ServiceConfig macie2(); AccountServiceConfig account(); AccessAnalyzerServiceConfig accessanalyzer(); @@ -762,6 +763,11 @@ interface SsoAdminServiceConfig { boolean enabled(); } + interface SsoOidcServiceConfig { + @WithDefault("true") + boolean enabled(); + } + interface Macie2ServiceConfig { @WithDefault("true") boolean enabled(); diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java index 521899d049..88d30ff457 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java @@ -40,6 +40,7 @@ import io.github.hectorvent.floci.services.accessanalyzer.AccessAnalyzerController; import io.github.hectorvent.floci.services.inspector2.Inspector2Controller; import io.github.hectorvent.floci.services.securityhub.SecurityHubController; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcController; import io.github.hectorvent.floci.services.detective.DetectiveController; import io.github.hectorvent.floci.services.aps.ApsController; import io.github.hectorvent.floci.services.controlcatalog.ControlCatalogController; @@ -426,6 +427,10 @@ public ResolvedServiceCatalog(EmulatorConfig config) { null, null, 5000L, null, ServiceProtocol.JSON, protocols(ServiceProtocol.JSON), Set.of("SWBExternalService."), Set.of("sso"), Set.of(), Set.of()), + descriptor("sso-oidc", "ssooidc", config.services().ssooidc().enabled(), true, + "ssooidc", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON, + protocols(ServiceProtocol.REST_JSON), + Set.of(), Set.of("sso-oauth"), Set.of(), Set.of(SsoOidcController.class)), descriptor("macie2", "macie2", config.services().macie2().enabled(), true, "macie2", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON, protocols(ServiceProtocol.REST_JSON), Set.of(), Set.of("macie2"), Set.of(), Set.of(MacieController.class)), diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java new file mode 100644 index 0000000000..1853d66ec8 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -0,0 +1,67 @@ +package io.github.hectorvent.floci.services.ssooidc; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/") +@Produces(MediaType.APPLICATION_JSON) +public class SsoOidcController { + private final SsoOidcService service; + private final ObjectMapper objectMapper; + + @Inject + public SsoOidcController(SsoOidcService service, ObjectMapper objectMapper) { + this.service = service; + this.objectMapper = objectMapper; + } + + @POST + @Path("/client/register") + @Consumes(MediaType.APPLICATION_JSON) + public Response registerClient(String body) { + try { + RegisteredClient client = service.registerClient(readTree(body)); + ObjectNode response = objectMapper.createObjectNode(); + response.put("clientId", client.clientId()); + response.put("clientSecret", client.clientSecret()); + response.put("clientIdIssuedAt", client.clientIdIssuedAt()); + response.put("clientSecretExpiresAt", client.clientSecretExpiresAt()); + response.put("authorizationEndpoint", service.authorizationEndpoint()); + response.put("tokenEndpoint", service.tokenEndpoint()); + return Response.ok(response).build(); + } catch (SsoOidcException e) { + return oidcError(e.status(), e.error(), e.getMessage()); + } catch (RuntimeException e) { + return oidcError(500, "server_error", "The request could not be processed"); + } + } + + private JsonNode readTree(String body) { + try { + return objectMapper.reader() + .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree(body == null || body.isBlank() ? "{}" : body); + } catch (Exception e) { + throw new SsoOidcException("invalid_request", "Request body must be valid JSON", 400); + } + } + + private Response oidcError(int status, String error, String description) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("error", error); + response.put("error_description", description); + return Response.status(status).type(MediaType.APPLICATION_JSON).entity(response).build(); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcException.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcException.java new file mode 100644 index 0000000000..6d00f204e1 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcException.java @@ -0,0 +1,20 @@ +package io.github.hectorvent.floci.services.ssooidc; + +public class SsoOidcException extends RuntimeException { + private final String error; + private final int status; + + public SsoOidcException(String error, String description, int status) { + super(description); + this.error = error; + this.status = status; + } + + public String error() { + return error; + } + + public int status() { + return status; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java new file mode 100644 index 0000000000..70fa218b40 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -0,0 +1,164 @@ +package io.github.hectorvent.floci.services.ssooidc; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import io.github.hectorvent.floci.config.EmulatorConfig; +import io.github.hectorvent.floci.core.common.Resettable; +import io.github.hectorvent.floci.core.storage.StorageBackend; +import io.github.hectorvent.floci.core.storage.StorageFactory; +import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@ApplicationScoped +public class SsoOidcService implements Resettable { + private static final long CLIENT_SECRET_LIFETIME_SECONDS = 90L * 24L * 60L * 60L; + private static final Set SUPPORTED_GRANT_TYPES = Set.of( + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token"); + private static final Pattern APPLICATION_ARN = Pattern.compile( + "arn:aws(?:-[a-z]{1,5}){0,3}:sso::[0-9]{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); + + private final StorageBackend clients; + private final String baseUrl; + + @Inject + public SsoOidcService(StorageFactory storageFactory, EmulatorConfig config) { + this(storageFactory.create("ssooidc", "ssooidc-registered-clients.json", + new TypeReference>() {}), + trimTrailingSlash(config.effectiveBaseUrl())); + } + + SsoOidcService(StorageBackend clients, String baseUrl) { + this.clients = clients; + this.baseUrl = trimTrailingSlash(baseUrl); + } + + public synchronized RegisteredClient registerClient(JsonNode request) { + String clientName = requiredText(request, "clientName"); + if (clientName.isBlank()) { + throw invalidClientMetadata("clientName must not be empty"); + } + String clientType = requiredText(request, "clientType"); + if (!"public".equals(clientType)) { + throw invalidClientMetadata("clientType must be public"); + } + + List scopes = optionalStringList(request, "scopes", "invalid_scope"); + List redirectUris = optionalStringList(request, "redirectUris", "invalid_redirect_uri"); + List grantTypes = optionalStringList(request, "grantTypes", "unsupported_grant_type"); + for (String grantType : grantTypes) { + if (!SUPPORTED_GRANT_TYPES.contains(grantType)) { + throw new SsoOidcException("unsupported_grant_type", + "Unsupported grant type: " + grantType, 400); + } + } + + String issuerUrl = optionalText(request, "issuerUrl"); + if (issuerUrl != null && issuerUrl.isBlank()) { + throw invalidClientMetadata("issuerUrl must not be empty"); + } + String entitledApplicationArn = optionalText(request, "entitledApplicationArn"); + if (entitledApplicationArn != null && !APPLICATION_ARN.matcher(entitledApplicationArn).matches()) { + throw invalidClientMetadata("entitledApplicationArn is invalid"); + } + + long issuedAt = System.currentTimeMillis() / 1000L; + String clientId = UUID.randomUUID().toString().replace("-", ""); + String clientSecret = UUID.randomUUID().toString().replace("-", "") + + UUID.randomUUID().toString().replace("-", ""); + RegisteredClient client = new RegisteredClient( + clientId, + clientSecret, + issuedAt, + issuedAt + CLIENT_SECRET_LIFETIME_SECONDS, + clientName, + clientType, + scopes, + redirectUris, + grantTypes, + issuerUrl, + entitledApplicationArn); + clients.put(clientId, client); + return client; + } + + public RegisteredClient requireClient(String clientId) { + if (clientId == null || clientId.isBlank()) { + throw new SsoOidcException("invalid_client", "clientId is required", 400); + } + return clients.get(clientId) + .orElseThrow(() -> new SsoOidcException("invalid_client", "Client not found", 401)); + } + + public String authorizationEndpoint() { + return baseUrl + "/authorize"; + } + + public String tokenEndpoint() { + return baseUrl + "/token"; + } + + @Override + public void clear() { + clients.clear(); + } + + private static String requiredText(JsonNode request, String field) { + String value = optionalText(request, field); + if (value == null) { + throw new SsoOidcException("invalid_request", field + " is required", 400); + } + return value; + } + + private static String optionalText(JsonNode request, String field) { + if (request == null || !request.has(field) || request.get(field).isNull()) { + return null; + } + JsonNode value = request.get(field); + if (!value.isTextual()) { + throw new SsoOidcException("invalid_request", field + " must be a string", 400); + } + return value.textValue(); + } + + private static List optionalStringList(JsonNode request, String field, String semanticError) { + if (request == null || !request.has(field) || request.get(field).isNull()) { + return List.of(); + } + JsonNode node = request.get(field); + if (!node.isArray()) { + throw new SsoOidcException("invalid_request", field + " must be an array", 400); + } + LinkedHashSet values = new LinkedHashSet<>(); + for (JsonNode item : node) { + if (!item.isTextual() || item.textValue().isBlank()) { + String description = field + " must contain non-empty strings"; + throw new SsoOidcException(semanticError, description, 400); + } + values.add(item.textValue()); + } + return new ArrayList<>(values); + } + + private static SsoOidcException invalidClientMetadata(String description) { + return new SsoOidcException("invalid_client_metadata", description, 400); + } + + private static String trimTrailingSlash(String value) { + if (value == null || value.isBlank()) { + return "http://localhost:4566"; + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java new file mode 100644 index 0000000000..f413bdc497 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java @@ -0,0 +1,23 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +import java.util.List; + +public record RegisteredClient( + String clientId, + String clientSecret, + long clientIdIssuedAt, + long clientSecretExpiresAt, + String clientName, + String clientType, + List scopes, + List redirectUris, + List grantTypes, + String issuerUrl, + String entitledApplicationArn +) { + public RegisteredClient { + scopes = scopes == null ? List.of() : List.copyOf(scopes); + redirectUris = redirectUris == null ? List.of() : List.copyOf(redirectUris); + grantTypes = grantTypes == null ? List.of() : List.copyOf(grantTypes); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java new file mode 100644 index 0000000000..86ef2b1de1 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -0,0 +1,41 @@ +package io.github.hectorvent.floci.services.ssooidc; + +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.matchesPattern; + +@QuarkusTest +class SsoOidcIntegrationTest { + + @Test + void registerClientReturnsAwsOidcShapeWithoutSigV4() { + given() + .contentType("application/json") + .body("{\"clientName\":\"Floci CLI\",\"clientType\":\"public\"," + + "\"grantTypes\":[\"authorization_code\",\"refresh_token\"]," + + "\"redirectUris\":[\"http://127.0.0.1:8400/callback\"]," + + "\"scopes\":[\"sso:account:access\"]}") + .when().post("/client/register") + .then().statusCode(200) + .body("clientId", matchesPattern("[0-9a-f]{32}")) + .body("clientSecret", matchesPattern("[0-9a-f]{64}")) + .body("clientIdIssuedAt", greaterThan(0)) + .body("clientSecretExpiresAt", greaterThan(0)) + .body("authorizationEndpoint", equalTo("http://localhost:4566/authorize")) + .body("tokenEndpoint", equalTo("http://localhost:4566/token")); + } + + @Test + void registerClientReturnsOidcErrorShape() { + given() + .contentType("application/json") + .body("{\"clientName\":\"Bad Client\",\"clientType\":\"confidential\"}") + .when().post("/client/register") + .then().statusCode(400) + .body("error", equalTo("invalid_client_metadata")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java new file mode 100644 index 0000000000..ab16b9cbcd --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -0,0 +1,83 @@ +package io.github.hectorvent.floci.services.ssooidc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SsoOidcServiceTest { + private final ObjectMapper mapper = new ObjectMapper(); + private SsoOidcService service; + + @BeforeEach + void setUp() { + service = new SsoOidcService(new InMemoryStorage<>(), "http://localhost:4566/"); + } + + @Test + void registerClientPersistsAwsOidcMetadata() { + ObjectNode request = mapper.createObjectNode(); + request.put("clientName", "Floci CLI"); + request.put("clientType", "public"); + request.putArray("scopes").add("sso:account:access"); + request.putArray("redirectUris").add("http://127.0.0.1:8400/callback"); + request.putArray("grantTypes") + .add("authorization_code") + .add("refresh_token"); + + RegisteredClient client = service.registerClient(request); + + assertNotNull(client.clientId()); + assertEquals(32, client.clientId().length()); + assertNotNull(client.clientSecret()); + assertEquals(64, client.clientSecret().length()); + assertTrue(client.clientSecretExpiresAt() > client.clientIdIssuedAt()); + assertEquals(client, service.requireClient(client.clientId())); + assertEquals("http://localhost:4566/authorize", service.authorizationEndpoint()); + assertEquals("http://localhost:4566/token", service.tokenEndpoint()); + } + + @Test + void registerClientValidatesPublicClientAndGrantType() { + ObjectNode confidential = mapper.createObjectNode(); + confidential.put("clientName", "Bad Client"); + confidential.put("clientType", "confidential"); + assertOidcError("invalid_client_metadata", () -> service.registerClient(confidential)); + + ObjectNode unsupportedGrant = mapper.createObjectNode(); + unsupportedGrant.put("clientName", "Bad Grant"); + unsupportedGrant.put("clientType", "public"); + unsupportedGrant.putArray("grantTypes").add("client_credentials"); + assertOidcError("unsupported_grant_type", () -> service.registerClient(unsupportedGrant)); + } + + @Test + void registerClientValidatesRequestShapes() { + ObjectNode missingName = mapper.createObjectNode().put("clientType", "public"); + assertOidcError("invalid_request", () -> service.registerClient(missingName)); + + ObjectNode badScopes = mapper.createObjectNode(); + badScopes.put("clientName", "Bad Scope"); + badScopes.put("clientType", "public"); + badScopes.put("scopes", "not-an-array"); + assertOidcError("invalid_request", () -> service.registerClient(badScopes)); + + ObjectNode badArn = mapper.createObjectNode(); + badArn.put("clientName", "Bad ARN"); + badArn.put("clientType", "public"); + badArn.put("entitledApplicationArn", "not-an-arn"); + assertOidcError("invalid_client_metadata", () -> service.registerClient(badArn)); + } + + private static void assertOidcError(String code, Runnable action) { + SsoOidcException error = assertThrows(SsoOidcException.class, action::run); + assertEquals(code, error.error()); + } +} diff --git a/tools/docs/service_matrix.yaml b/tools/docs/service_matrix.yaml index 0b1bdfcc12..e410406c35 100644 --- a/tools/docs/service_matrix.yaml +++ b/tools/docs/service_matrix.yaml @@ -26,6 +26,7 @@ aliases: mq: amazonmq # Amazon MQ's SigV4 signing name is "mq" signin: iam # AWS Sign-In facet, at iam.md#aws-sign-in-login-credentials sso: iam # Identity Center ListInstances facet on the IAM page + sso-oidc: ssooidc # IAM Identity Center OIDC REST API events: eventbridge servicediscovery: cloudmap # Cloud Map's SigV4 signing name elasticmapreduce: emr # EMR's SigV4 signing name From 26e5df501cb996aba14b9d6f2d88d279a4f5deeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 21:41:30 -0400 Subject: [PATCH 83/99] feat(ssooidc): start device authorization --- .../test/java/com/floci/test/SsoOidcTest.java | 24 +++++++ docs/services/index.md | 2 +- docs/services/ssooidc.md | 3 + .../services/ssooidc/SsoOidcController.java | 23 ++++++ .../services/ssooidc/SsoOidcService.java | 71 ++++++++++++++++++- .../ssooidc/model/DeviceAuthorization.java | 11 +++ .../ssooidc/SsoOidcIntegrationTest.java | 33 +++++++++ .../services/ssooidc/SsoOidcServiceTest.java | 27 ++++++- 8 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index 3679b4b5c5..f49de18419 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -31,4 +31,28 @@ void registerClientUsesAwsSdk() { assertThat(response.tokenEndpoint()).endsWith("/token"); } } + + @Test + @DisplayName("starts device authorization through the AWS SDK") + void startDeviceAuthorizationUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only device authorization"); + + try (SsoOidcClient oidc = TestFixtures.ssoOidcClient()) { + var client = oidc.registerClient(request -> request + .clientName("Floci Device SDK") + .clientType("public") + .grantTypes("urn:ietf:params:oauth:grant-type:device_code")); + var response = oidc.startDeviceAuthorization(request -> request + .clientId(client.clientId()) + .clientSecret(client.clientSecret()) + .startUrl("https://example.awsapps.com/start")); + + assertThat(response.deviceCode()).matches("[0-9a-f]{64}"); + assertThat(response.userCode()).matches("[0-9A-F]{4}-[0-9A-F]{4}"); + assertThat(response.verificationUri()).endsWith("/device"); + assertThat(response.verificationUriComplete()).contains("user_code=" + response.userCode()); + assertThat(response.expiresIn()).isPositive(); + assertThat(response.interval()).isEqualTo(5); + } + } } diff --git a/docs/services/index.md b/docs/services/index.md index d533edebb1..91be3f6a31 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -47,7 +47,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [AWS Account Management](account.md) | `/putAlternateContact`, `/getAlternateContact` | REST JSON | 2 | | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | -| [IAM Identity Center OIDC](ssooidc.md) | `/client/register` | REST JSON | 1 | +| [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization` | REST JSON | 2 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssooidc.md b/docs/services/ssooidc.md index 39492a6c46..345403e5a6 100644 --- a/docs/services/ssooidc.md +++ b/docs/services/ssooidc.md @@ -12,10 +12,13 @@ Floci emulates the IAM Identity Center OIDC registration and token endpoints use | Action | Description | | --- | --- | | `RegisterClient` | Registers a public OIDC client, persists its generated client credentials, and returns local authorization and token endpoints. | +| `StartDeviceAuthorization` | Validates a registered public client and creates a persisted short-lived device authorization challenge. | `RegisterClient` supports the authorization-code, device-code, and refresh-token grant identifiers documented by AWS. Client registrations are persisted so later device authorization and token operations can authenticate the generated client ID and secret. +`StartDeviceAuthorization` validates the client credentials and stores the generated device and user codes for later token polling. Floci uses a 10-minute device-code lifetime and a 5-second polling interval as local emulator defaults. + OIDC failures use the AWS response shape with `error` and `error_description`. The local emulator assigns a 90-day client-secret lifetime; AWS documents the expiration timestamp but does not publish a fixed lifetime for this operation. See the [IAM Identity Center OIDC API Reference](https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java index 1853d66ec8..cfe2377ef3 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -48,6 +49,28 @@ public Response registerClient(String body) { } } + @POST + @Path("/device_authorization") + @Consumes(MediaType.APPLICATION_JSON) + public Response startDeviceAuthorization(String body) { + try { + DeviceAuthorization authorization = service.startDeviceAuthorization(readTree(body)); + ObjectNode response = objectMapper.createObjectNode(); + response.put("deviceCode", authorization.deviceCode()); + response.put("userCode", authorization.userCode()); + response.put("verificationUri", service.verificationUri()); + response.put("verificationUriComplete", service.verificationUriComplete(authorization)); + response.put("expiresIn", (int) (authorization.expiresAtEpochSeconds() + - System.currentTimeMillis() / 1000L)); + response.put("interval", authorization.intervalSeconds()); + return Response.ok(response).build(); + } catch (SsoOidcException e) { + return oidcError(e.status(), e.error(), e.getMessage()); + } catch (RuntimeException e) { + return oidcError(500, "server_error", "The request could not be processed"); + } + } + private JsonNode readTree(String body) { try { return objectMapper.reader() diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index 70fa218b40..3f268a0036 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -6,6 +6,7 @@ import io.github.hectorvent.floci.core.common.Resettable; import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageFactory; +import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -21,6 +22,8 @@ @ApplicationScoped public class SsoOidcService implements Resettable { private static final long CLIENT_SECRET_LIFETIME_SECONDS = 90L * 24L * 60L * 60L; + private static final int DEVICE_CODE_LIFETIME_SECONDS = 600; + private static final int DEVICE_POLL_INTERVAL_SECONDS = 5; private static final Set SUPPORTED_GRANT_TYPES = Set.of( "authorization_code", "urn:ietf:params:oauth:grant-type:device_code", @@ -29,17 +32,23 @@ public class SsoOidcService implements Resettable { "arn:aws(?:-[a-z]{1,5}){0,3}:sso::[0-9]{12}:application/(?:sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}"); private final StorageBackend clients; + private final StorageBackend deviceAuthorizations; private final String baseUrl; @Inject public SsoOidcService(StorageFactory storageFactory, EmulatorConfig config) { this(storageFactory.create("ssooidc", "ssooidc-registered-clients.json", new TypeReference>() {}), + storageFactory.create("ssooidc", "ssooidc-device-authorizations.json", + new TypeReference>() {}), trimTrailingSlash(config.effectiveBaseUrl())); } - SsoOidcService(StorageBackend clients, String baseUrl) { + SsoOidcService(StorageBackend clients, + StorageBackend deviceAuthorizations, + String baseUrl) { this.clients = clients; + this.deviceAuthorizations = deviceAuthorizations; this.baseUrl = trimTrailingSlash(baseUrl); } @@ -92,6 +101,39 @@ public synchronized RegisteredClient registerClient(JsonNode request) { return client; } + public synchronized DeviceAuthorization startDeviceAuthorization(JsonNode request) { + String clientId = requiredText(request, "clientId"); + String clientSecret = requiredText(request, "clientSecret"); + String startUrl = requiredText(request, "startUrl"); + if (startUrl.isBlank()) { + throw new SsoOidcException("invalid_request", "startUrl must not be empty", 400); + } + RegisteredClient client = requireClientCredentials(clientId, clientSecret); + if (!"public".equals(client.clientType())) { + throw new SsoOidcException("unauthorized_client", "Client is not a public client", 400); + } + String deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"; + if (!client.grantTypes().isEmpty() && !client.grantTypes().contains(deviceGrant)) { + throw new SsoOidcException("unauthorized_client", "Client is not registered for the device code grant", 400); + } + + long now = System.currentTimeMillis() / 1000L; + String deviceCode = UUID.randomUUID().toString().replace("-", "") + + UUID.randomUUID().toString().replace("-", ""); + String rawUserCode = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + String userCode = rawUserCode.substring(0, 4) + "-" + rawUserCode.substring(4); + DeviceAuthorization authorization = new DeviceAuthorization( + deviceCode, + userCode, + clientId, + startUrl, + now + DEVICE_CODE_LIFETIME_SECONDS, + DEVICE_POLL_INTERVAL_SECONDS, + false); + deviceAuthorizations.put(deviceCode, authorization); + return authorization; + } + public RegisteredClient requireClient(String clientId) { if (clientId == null || clientId.isBlank()) { throw new SsoOidcException("invalid_client", "clientId is required", 400); @@ -100,6 +142,32 @@ public RegisteredClient requireClient(String clientId) { .orElseThrow(() -> new SsoOidcException("invalid_client", "Client not found", 401)); } + public RegisteredClient requireClientCredentials(String clientId, String clientSecret) { + RegisteredClient client = requireClient(clientId); + long now = System.currentTimeMillis() / 1000L; + if (clientSecret == null || !client.clientSecret().equals(clientSecret) + || client.clientSecretExpiresAt() <= now) { + throw new SsoOidcException("invalid_client", "Client credentials are invalid or expired", 401); + } + return client; + } + + public DeviceAuthorization requireDeviceAuthorization(String deviceCode) { + if (deviceCode == null || deviceCode.isBlank()) { + throw new SsoOidcException("invalid_request", "deviceCode is required", 400); + } + return deviceAuthorizations.get(deviceCode) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Device code is invalid", 400)); + } + + public String verificationUri() { + return baseUrl + "/device"; + } + + public String verificationUriComplete(DeviceAuthorization authorization) { + return verificationUri() + "?user_code=" + authorization.userCode(); + } + public String authorizationEndpoint() { return baseUrl + "/authorize"; } @@ -111,6 +179,7 @@ public String tokenEndpoint() { @Override public void clear() { clients.clear(); + deviceAuthorizations.clear(); } private static String requiredText(JsonNode request, String field) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java new file mode 100644 index 0000000000..6770286c37 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java @@ -0,0 +1,11 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +public record DeviceAuthorization( + String deviceCode, + String userCode, + String clientId, + String startUrl, + long expiresAtEpochSeconds, + int intervalSeconds, + boolean authorized +) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java index 86ef2b1de1..9a2a5f8fba 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -29,6 +29,39 @@ void registerClientReturnsAwsOidcShapeWithoutSigV4() { .body("tokenEndpoint", equalTo("http://localhost:4566/token")); } + @Test + void startDeviceAuthorizationUsesRegisteredClient() { + var registration = given() + .contentType("application/json") + .body("{\"clientName\":\"Device Integration\",\"clientType\":\"public\"," + + "\"grantTypes\":[\"urn:ietf:params:oauth:grant-type:device_code\"]}") + .when().post("/client/register") + .then().statusCode(200) + .extract().response(); + String clientId = registration.path("clientId"); + String clientSecret = registration.path("clientSecret"); + + given() + .contentType("application/json") + .body("{\"clientId\":\"" + clientId + "\",\"clientSecret\":\"" + clientSecret + + "\",\"startUrl\":\"https://example.awsapps.com/start\"}") + .when().post("/device_authorization") + .then().statusCode(200) + .body("deviceCode", matchesPattern("[0-9a-f]{64}")) + .body("userCode", matchesPattern("[0-9A-F]{4}-[0-9A-F]{4}")) + .body("verificationUri", equalTo("http://localhost:4566/device")) + .body("expiresIn", greaterThan(0)) + .body("interval", equalTo(5)); + + given() + .contentType("application/json") + .body("{\"clientId\":\"" + clientId + "\",\"clientSecret\":\"wrong\"," + + "\"startUrl\":\"https://example.awsapps.com/start\"}") + .when().post("/device_authorization") + .then().statusCode(401) + .body("error", equalTo("invalid_client")); + } + @Test void registerClientReturnsOidcErrorShape() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java index ab16b9cbcd..a52448e838 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -18,7 +18,7 @@ class SsoOidcServiceTest { @BeforeEach void setUp() { - service = new SsoOidcService(new InMemoryStorage<>(), "http://localhost:4566/"); + service = new SsoOidcService(new InMemoryStorage<>(), new InMemoryStorage<>(), "http://localhost:4566/"); } @Test @@ -44,6 +44,31 @@ void registerClientPersistsAwsOidcMetadata() { assertEquals("http://localhost:4566/token", service.tokenEndpoint()); } + @Test + void startDeviceAuthorizationValidatesCredentialsAndPersistsChallenge() { + ObjectNode register = mapper.createObjectNode(); + register.put("clientName", "Device Client"); + register.put("clientType", "public"); + register.putArray("grantTypes").add("urn:ietf:params:oauth:grant-type:device_code"); + RegisteredClient client = service.registerClient(register); + + ObjectNode request = mapper.createObjectNode(); + request.put("clientId", client.clientId()); + request.put("clientSecret", client.clientSecret()); + request.put("startUrl", "https://example.awsapps.com/start"); + var authorization = service.startDeviceAuthorization(request); + + assertEquals(client.clientId(), authorization.clientId()); + assertEquals(64, authorization.deviceCode().length()); + assertTrue(authorization.userCode().matches("[0-9A-F]{4}-[0-9A-F]{4}")); + assertEquals(authorization, service.requireDeviceAuthorization(authorization.deviceCode())); + assertEquals("http://localhost:4566/device", service.verificationUri()); + assertTrue(service.verificationUriComplete(authorization).endsWith("user_code=" + authorization.userCode())); + + ObjectNode wrongSecret = request.deepCopy().put("clientSecret", "wrong"); + assertOidcError("invalid_client", () -> service.startDeviceAuthorization(wrongSecret)); + } + @Test void registerClientValidatesPublicClientAndGrantType() { ObjectNode confidential = mapper.createObjectNode(); From 3526c8f89a69a80039bcfd316676062b521df763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 21:45:34 -0400 Subject: [PATCH 84/99] feat(ssooidc): create public client tokens --- .../test/java/com/floci/test/SsoOidcTest.java | 28 +++ docs/services/index.md | 2 +- docs/services/ssooidc.md | 3 + .../services/ssooidc/SsoOidcController.java | 69 +++++++ .../services/ssooidc/SsoOidcService.java | 169 +++++++++++++++++- .../ssooidc/model/AuthorizationCode.java | 9 + .../ssooidc/model/DeviceAuthorization.java | 3 +- .../services/ssooidc/model/TokenSession.java | 16 ++ .../ssooidc/SsoOidcIntegrationTest.java | 46 +++++ .../services/ssooidc/SsoOidcServiceTest.java | 77 +++++++- 10 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index f49de18419..420b567948 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -53,6 +53,34 @@ void startDeviceAuthorizationUsesAwsSdk() { assertThat(response.verificationUriComplete()).contains("user_code=" + response.userCode()); assertThat(response.expiresIn()).isPositive(); assertThat(response.interval()).isEqualTo(5); + + try { + var request = java.net.http.HttpRequest.newBuilder(java.net.URI.create(response.verificationUriComplete())).GET().build(); + var browserResponse = java.net.http.HttpClient.newHttpClient().send( + request, java.net.http.HttpResponse.BodyHandlers.ofString()); + assertThat(browserResponse.statusCode()).isEqualTo(200); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + var token = oidc.createToken(request -> request + .clientId(client.clientId()) + .clientSecret(client.clientSecret()) + .grantType("urn:ietf:params:oauth:grant-type:device_code") + .deviceCode(response.deviceCode())); + assertThat(token.tokenType()).isEqualTo("Bearer"); + assertThat(token.accessToken()).isNotBlank(); + assertThat(token.refreshToken()).isNotBlank(); + + var refreshed = oidc.createToken(request -> request + .clientId(client.clientId()) + .clientSecret(client.clientSecret()) + .grantType("refresh_token") + .refreshToken(token.refreshToken())); + assertThat(refreshed.accessToken()).isNotEqualTo(token.accessToken()); } } } diff --git a/docs/services/index.md b/docs/services/index.md index 91be3f6a31..283f60f02e 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -47,7 +47,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [AWS Account Management](account.md) | `/putAlternateContact`, `/getAlternateContact` | REST JSON | 2 | | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | -| [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization` | REST JSON | 2 | +| [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/authorize`, `/device` | REST JSON | 3 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssooidc.md b/docs/services/ssooidc.md index 345403e5a6..ae86e97e8d 100644 --- a/docs/services/ssooidc.md +++ b/docs/services/ssooidc.md @@ -13,12 +13,15 @@ Floci emulates the IAM Identity Center OIDC registration and token endpoints use | --- | --- | | `RegisterClient` | Registers a public OIDC client, persists its generated client credentials, and returns local authorization and token endpoints. | | `StartDeviceAuthorization` | Validates a registered public client and creates a persisted short-lived device authorization challenge. | +| `CreateToken` | Exchanges device, PKCE authorization-code, or refresh-token grants for persisted bearer and refresh tokens. | `RegisterClient` supports the authorization-code, device-code, and refresh-token grant identifiers documented by AWS. Client registrations are persisted so later device authorization and token operations can authenticate the generated client ID and secret. `StartDeviceAuthorization` validates the client credentials and stores the generated device and user codes for later token polling. Floci uses a 10-minute device-code lifetime and a 5-second polling interval as local emulator defaults. +`CreateToken` supports all three AWS-documented public-client grants. Device authorization can be completed locally by visiting the returned `/device?user_code=...` URL. Authorization Code uses the local `/authorize` endpoint with PKCE S256 and registered redirect URIs. Floci issues one-hour access tokens and 30-day refresh tokens as emulator defaults; the public API documentation does not define fixed lifetimes for these values. The `scope` request is intentionally ignored because AWS states that this operation always grants the scopes configured during client registration. + OIDC failures use the AWS response shape with `error` and `error_description`. The local emulator assigns a 90-day client-secret lifetime; AWS documents the expiration timestamp but does not publish a fixed lifetime for this operation. See the [IAM Identity Center OIDC API Reference](https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java index cfe2377ef3..8237caf5ee 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -4,17 +4,25 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.services.ssooidc.model.AuthorizationCode; import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + @ApplicationScoped @Path("/") @Produces(MediaType.APPLICATION_JSON) @@ -71,6 +79,67 @@ public Response startDeviceAuthorization(String body) { } } + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_JSON) + public Response createToken(String body) { + try { + TokenSession session = service.createToken(readTree(body)); + ObjectNode response = objectMapper.createObjectNode(); + response.put("accessToken", session.accessToken()); + response.put("refreshToken", session.refreshToken()); + response.put("expiresIn", (int) (session.accessTokenExpiresAtEpochSeconds() + - System.currentTimeMillis() / 1000L)); + response.put("tokenType", "Bearer"); + return Response.ok(response).build(); + } catch (SsoOidcException e) { + return oidcError(e.status(), e.error(), e.getMessage()); + } catch (RuntimeException e) { + return oidcError(500, "server_error", "The request could not be processed"); + } + } + + @GET + @Path("/device") + public Response authorizeDevice(@QueryParam("user_code") String userCode) { + try { + DeviceAuthorization authorization = service.authorizeDevice(userCode); + return Response.ok(objectMapper.createObjectNode() + .put("status", "authorized") + .put("userCode", authorization.userCode())).build(); + } catch (SsoOidcException e) { + return oidcError(e.status(), e.error(), e.getMessage()); + } + } + + @GET + @Path("/authorize") + public Response authorizeCode(@QueryParam("response_type") String responseType, + @QueryParam("client_id") String clientId, + @QueryParam("redirect_uri") String redirectUri, + @QueryParam("code_challenge") String codeChallenge, + @QueryParam("code_challenge_method") String codeChallengeMethod, + @QueryParam("state") String state) { + try { + if (!"code".equals(responseType)) { + throw new SsoOidcException("invalid_request", "response_type must be code", 400); + } + if (!"S256".equals(codeChallengeMethod)) { + throw new SsoOidcException("invalid_request", "code_challenge_method must be S256", 400); + } + AuthorizationCode authorization = service.createAuthorizationCode(clientId, redirectUri, codeChallenge); + String separator = redirectUri.contains("?") ? "&" : "?"; + String location = redirectUri + separator + "code=" + + URLEncoder.encode(authorization.code(), StandardCharsets.UTF_8); + if (state != null) { + location += "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8); + } + return Response.seeOther(URI.create(location)).build(); + } catch (SsoOidcException e) { + return oidcError(e.status(), e.error(), e.getMessage()); + } + } + private JsonNode readTree(String body) { try { return objectMapper.reader() diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index 3f268a0036..09e22ee8e8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -6,12 +6,18 @@ import io.github.hectorvent.floci.core.common.Resettable; import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageFactory; +import io.github.hectorvent.floci.services.ssooidc.model.AuthorizationCode; import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Base64; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -24,6 +30,9 @@ public class SsoOidcService implements Resettable { private static final long CLIENT_SECRET_LIFETIME_SECONDS = 90L * 24L * 60L * 60L; private static final int DEVICE_CODE_LIFETIME_SECONDS = 600; private static final int DEVICE_POLL_INTERVAL_SECONDS = 5; + private static final int AUTHORIZATION_CODE_LIFETIME_SECONDS = 300; + private static final int ACCESS_TOKEN_LIFETIME_SECONDS = 3600; + private static final long REFRESH_TOKEN_LIFETIME_SECONDS = 30L * 24L * 60L * 60L; private static final Set SUPPORTED_GRANT_TYPES = Set.of( "authorization_code", "urn:ietf:params:oauth:grant-type:device_code", @@ -33,6 +42,8 @@ public class SsoOidcService implements Resettable { private final StorageBackend clients; private final StorageBackend deviceAuthorizations; + private final StorageBackend authorizationCodes; + private final StorageBackend tokenSessions; private final String baseUrl; @Inject @@ -41,14 +52,22 @@ public SsoOidcService(StorageFactory storageFactory, EmulatorConfig config) { new TypeReference>() {}), storageFactory.create("ssooidc", "ssooidc-device-authorizations.json", new TypeReference>() {}), + storageFactory.create("ssooidc", "ssooidc-authorization-codes.json", + new TypeReference>() {}), + storageFactory.create("ssooidc", "ssooidc-token-sessions.json", + new TypeReference>() {}), trimTrailingSlash(config.effectiveBaseUrl())); } SsoOidcService(StorageBackend clients, StorageBackend deviceAuthorizations, + StorageBackend authorizationCodes, + StorageBackend tokenSessions, String baseUrl) { this.clients = clients; this.deviceAuthorizations = deviceAuthorizations; + this.authorizationCodes = authorizationCodes; + this.tokenSessions = tokenSessions; this.baseUrl = trimTrailingSlash(baseUrl); } @@ -129,11 +148,142 @@ public synchronized DeviceAuthorization startDeviceAuthorization(JsonNode reques startUrl, now + DEVICE_CODE_LIFETIME_SECONDS, DEVICE_POLL_INTERVAL_SECONDS, - false); + false, + 0L); deviceAuthorizations.put(deviceCode, authorization); return authorization; } + public synchronized DeviceAuthorization authorizeDevice(String userCode) { + if (userCode == null || userCode.isBlank()) { + throw new SsoOidcException("invalid_request", "user_code is required", 400); + } + DeviceAuthorization authorization = deviceAuthorizations.scan(key -> true).stream() + .filter(item -> userCode.equals(item.userCode())) + .findFirst() + .orElseThrow(() -> new SsoOidcException("invalid_grant", "User code is invalid", 400)); + if (authorization.expiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + throw new SsoOidcException("expired_token", "Device code has expired", 400); + } + DeviceAuthorization authorized = new DeviceAuthorization( + authorization.deviceCode(), authorization.userCode(), authorization.clientId(), authorization.startUrl(), + authorization.expiresAtEpochSeconds(), authorization.intervalSeconds(), true, + authorization.lastPollAtEpochMillis()); + deviceAuthorizations.put(authorization.deviceCode(), authorized); + return authorized; + } + + public synchronized AuthorizationCode createAuthorizationCode( + String clientId, String redirectUri, String codeChallenge) { + RegisteredClient client = requireClient(clientId); + if (!client.grantTypes().isEmpty() && !client.grantTypes().contains("authorization_code")) { + throw new SsoOidcException("unauthorized_client", "Client is not registered for authorization code", 400); + } + if (redirectUri == null || redirectUri.isBlank() || !client.redirectUris().contains(redirectUri)) { + throw new SsoOidcException("invalid_redirect_uri", "Redirect URI is not registered", 400); + } + if (codeChallenge == null || codeChallenge.isBlank()) { + throw new SsoOidcException("invalid_request", "code_challenge is required", 400); + } + String code = randomToken(); + AuthorizationCode authorizationCode = new AuthorizationCode( + code, clientId, redirectUri, codeChallenge, + System.currentTimeMillis() / 1000L + AUTHORIZATION_CODE_LIFETIME_SECONDS); + authorizationCodes.put(code, authorizationCode); + return authorizationCode; + } + + public synchronized TokenSession createToken(JsonNode request) { + String clientId = requiredText(request, "clientId"); + String clientSecret = requiredText(request, "clientSecret"); + RegisteredClient client = requireClientCredentials(clientId, clientSecret); + String grantType = requiredText(request, "grantType"); + if (!SUPPORTED_GRANT_TYPES.contains(grantType)) { + throw new SsoOidcException("unsupported_grant_type", "Unsupported grant type: " + grantType, 400); + } + if (!client.grantTypes().isEmpty() && !client.grantTypes().contains(grantType)) { + throw new SsoOidcException("unauthorized_client", "Client is not registered for this grant type", 400); + } + + return switch (grantType) { + case "urn:ietf:params:oauth:grant-type:device_code" -> createTokenFromDeviceCode(request, client); + case "authorization_code" -> createTokenFromAuthorizationCode(request, client); + case "refresh_token" -> createTokenFromRefreshToken(request, client); + default -> throw new SsoOidcException("unsupported_grant_type", "Unsupported grant type", 400); + }; + } + + private TokenSession createTokenFromDeviceCode(JsonNode request, RegisteredClient client) { + String deviceCode = requiredText(request, "deviceCode"); + DeviceAuthorization authorization = requireDeviceAuthorization(deviceCode); + if (!client.clientId().equals(authorization.clientId())) { + throw new SsoOidcException("invalid_grant", "Device code belongs to another client", 400); + } + long nowSeconds = System.currentTimeMillis() / 1000L; + long nowMillis = System.currentTimeMillis(); + if (authorization.expiresAtEpochSeconds() <= nowSeconds) { + deviceAuthorizations.delete(deviceCode); + throw new SsoOidcException("expired_token", "Device code has expired", 400); + } + if (authorization.lastPollAtEpochMillis() > 0 + && nowMillis - authorization.lastPollAtEpochMillis() < authorization.intervalSeconds() * 1000L) { + throw new SsoOidcException("slow_down", "Token polling is too frequent", 400); + } + if (!authorization.authorized()) { + deviceAuthorizations.put(deviceCode, new DeviceAuthorization( + authorization.deviceCode(), authorization.userCode(), authorization.clientId(), authorization.startUrl(), + authorization.expiresAtEpochSeconds(), authorization.intervalSeconds(), false, nowMillis)); + throw new SsoOidcException("authorization_pending", "Device authorization is pending", 400); + } + deviceAuthorizations.delete(deviceCode); + return issueToken(client); + } + + private TokenSession createTokenFromAuthorizationCode(JsonNode request, RegisteredClient client) { + String code = requiredText(request, "code"); + String codeVerifier = requiredText(request, "codeVerifier"); + String redirectUri = requiredText(request, "redirectUri"); + AuthorizationCode authorizationCode = authorizationCodes.get(code) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Authorization code is invalid", 400)); + if (authorizationCode.expiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + authorizationCodes.delete(code); + throw new SsoOidcException("expired_token", "Authorization code has expired", 400); + } + if (!client.clientId().equals(authorizationCode.clientId()) + || !redirectUri.equals(authorizationCode.redirectUri()) + || !pkceChallenge(codeVerifier).equals(authorizationCode.codeChallenge())) { + throw new SsoOidcException("invalid_grant", "Authorization code validation failed", 400); + } + authorizationCodes.delete(code); + return issueToken(client); + } + + private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredClient client) { + String refreshToken = requiredText(request, "refreshToken"); + TokenSession prior = tokenSessions.get("refresh:" + refreshToken) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Refresh token is invalid", 400)); + if (!client.clientId().equals(prior.clientId())) { + throw new SsoOidcException("invalid_grant", "Refresh token belongs to another client", 400); + } + if (prior.refreshTokenExpiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + tokenSessions.delete("refresh:" + refreshToken); + throw new SsoOidcException("expired_token", "Refresh token has expired", 400); + } + return issueToken(client); + } + + private TokenSession issueToken(RegisteredClient client) { + long now = System.currentTimeMillis() / 1000L; + String accessToken = randomToken(); + String refreshToken = randomToken(); + TokenSession session = new TokenSession( + accessToken, refreshToken, client.clientId(), client.scopes(), + now + ACCESS_TOKEN_LIFETIME_SECONDS, now + REFRESH_TOKEN_LIFETIME_SECONDS); + tokenSessions.put("access:" + accessToken, session); + tokenSessions.put("refresh:" + refreshToken, session); + return session; + } + public RegisteredClient requireClient(String clientId) { if (clientId == null || clientId.isBlank()) { throw new SsoOidcException("invalid_client", "clientId is required", 400); @@ -180,6 +330,8 @@ public String tokenEndpoint() { public void clear() { clients.clear(); deviceAuthorizations.clear(); + authorizationCodes.clear(); + tokenSessions.clear(); } private static String requiredText(JsonNode request, String field) { @@ -224,6 +376,21 @@ private static SsoOidcException invalidClientMetadata(String description) { return new SsoOidcException("invalid_client_metadata", description, 400); } + private static String randomToken() { + return UUID.randomUUID().toString().replace("-", "") + + UUID.randomUUID().toString().replace("-", ""); + } + + private static String pkceChallenge(String verifier) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + private static String trimTrailingSlash(String value) { if (value == null || value.isBlank()) { return "http://localhost:4566"; diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java new file mode 100644 index 0000000000..8bdd186a74 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java @@ -0,0 +1,9 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +public record AuthorizationCode( + String code, + String clientId, + String redirectUri, + String codeChallenge, + long expiresAtEpochSeconds +) {} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java index 6770286c37..158b1b5bc6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java @@ -7,5 +7,6 @@ public record DeviceAuthorization( String startUrl, long expiresAtEpochSeconds, int intervalSeconds, - boolean authorized + boolean authorized, + long lastPollAtEpochMillis ) {} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java new file mode 100644 index 0000000000..3b75563b1f --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java @@ -0,0 +1,16 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +import java.util.List; + +public record TokenSession( + String accessToken, + String refreshToken, + String clientId, + List scopes, + long accessTokenExpiresAtEpochSeconds, + long refreshTokenExpiresAtEpochSeconds +) { + public TokenSession { + scopes = scopes == null ? List.of() : List.copyOf(scopes); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java index 9a2a5f8fba..fc6751735b 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -62,6 +62,52 @@ void startDeviceAuthorizationUsesRegisteredClient() { .body("error", equalTo("invalid_client")); } + @Test + void createTokenCompletesDeviceFlowAndRefreshesToken() { + var registration = given() + .contentType("application/json") + .body("{\"clientName\":\"Token Integration\",\"clientType\":\"public\"," + + "\"grantTypes\":[\"urn:ietf:params:oauth:grant-type:device_code\",\"refresh_token\"]}") + .when().post("/client/register") + .then().statusCode(200) + .extract().response(); + String clientId = registration.path("clientId"); + String clientSecret = registration.path("clientSecret"); + var authorization = given() + .contentType("application/json") + .body("{\"clientId\":\"" + clientId + "\",\"clientSecret\":\"" + clientSecret + + "\",\"startUrl\":\"https://example.awsapps.com/start\"}") + .when().post("/device_authorization") + .then().statusCode(200) + .extract().response(); + String deviceCode = authorization.path("deviceCode"); + String userCode = authorization.path("userCode"); + + given().queryParam("user_code", userCode) + .when().get("/device") + .then().statusCode(200).body("status", equalTo("authorized")); + + var token = given() + .contentType("application/json") + .body("{\"clientId\":\"" + clientId + "\",\"clientSecret\":\"" + clientSecret + + "\",\"grantType\":\"urn:ietf:params:oauth:grant-type:device_code\"," + + "\"deviceCode\":\"" + deviceCode + "\"}") + .when().post("/token") + .then().statusCode(200) + .body("tokenType", equalTo("Bearer")) + .body("accessToken", matchesPattern("[0-9a-f]{64}")) + .extract().response(); + String refreshToken = token.path("refreshToken"); + + given() + .contentType("application/json") + .body("{\"clientId\":\"" + clientId + "\",\"clientSecret\":\"" + clientSecret + + "\",\"grantType\":\"refresh_token\",\"refreshToken\":\"" + refreshToken + "\"}") + .when().post("/token") + .then().statusCode(200) + .body("tokenType", equalTo("Bearer")); + } + @Test void registerClientReturnsOidcErrorShape() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java index a52448e838..2d34b76b47 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -18,7 +18,9 @@ class SsoOidcServiceTest { @BeforeEach void setUp() { - service = new SsoOidcService(new InMemoryStorage<>(), new InMemoryStorage<>(), "http://localhost:4566/"); + service = new SsoOidcService( + new InMemoryStorage<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + new InMemoryStorage<>(), "http://localhost:4566/"); } @Test @@ -69,6 +71,79 @@ void startDeviceAuthorizationValidatesCredentialsAndPersistsChallenge() { assertOidcError("invalid_client", () -> service.startDeviceAuthorization(wrongSecret)); } + @Test + void createTokenSupportsDeviceAndRefreshGrants() { + ObjectNode register = mapper.createObjectNode(); + register.put("clientName", "Token Client"); + register.put("clientType", "public"); + register.putArray("grantTypes") + .add("urn:ietf:params:oauth:grant-type:device_code") + .add("refresh_token"); + RegisteredClient client = service.registerClient(register); + ObjectNode start = mapper.createObjectNode(); + start.put("clientId", client.clientId()); + start.put("clientSecret", client.clientSecret()); + start.put("startUrl", "https://example.awsapps.com/start"); + var authorization = service.startDeviceAuthorization(start); + + ObjectNode token = mapper.createObjectNode(); + token.put("clientId", client.clientId()); + token.put("clientSecret", client.clientSecret()); + token.put("grantType", "urn:ietf:params:oauth:grant-type:device_code"); + token.put("deviceCode", authorization.deviceCode()); + assertOidcError("authorization_pending", () -> service.createToken(token)); + assertOidcError("slow_down", () -> service.createToken(token)); + + var approvedAuthorization = service.startDeviceAuthorization(start); + service.authorizeDevice(approvedAuthorization.userCode()); + token.put("deviceCode", approvedAuthorization.deviceCode()); + var session = service.createToken(token); + assertEquals("Token Client", client.clientName()); + assertEquals(64, session.accessToken().length()); + assertEquals(64, session.refreshToken().length()); + + ObjectNode refresh = mapper.createObjectNode(); + refresh.put("clientId", client.clientId()); + refresh.put("clientSecret", client.clientSecret()); + refresh.put("grantType", "refresh_token"); + refresh.put("refreshToken", session.refreshToken()); + assertEquals(client.clientId(), service.createToken(refresh).clientId()); + } + + @Test + void createTokenSupportsAuthorizationCodePkce() { + ObjectNode register = mapper.createObjectNode(); + register.put("clientName", "PKCE Client"); + register.put("clientType", "public"); + register.putArray("grantTypes").add("authorization_code"); + register.putArray("redirectUris").add("http://127.0.0.1:8400/callback"); + RegisteredClient client = service.registerClient(register); + String verifier = "01234567890123456789012345678901234567890123456789"; + String challenge = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString( + sha256(verifier)); + var code = service.createAuthorizationCode(client.clientId(), + "http://127.0.0.1:8400/callback", challenge); + + ObjectNode token = mapper.createObjectNode(); + token.put("clientId", client.clientId()); + token.put("clientSecret", client.clientSecret()); + token.put("grantType", "authorization_code"); + token.put("code", code.code()); + token.put("codeVerifier", verifier); + token.put("redirectUri", "http://127.0.0.1:8400/callback"); + assertEquals(client.clientId(), service.createToken(token).clientId()); + assertOidcError("invalid_grant", () -> service.createToken(token)); + } + + private static byte[] sha256(String value) { + try { + return java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + @Test void registerClientValidatesPublicClientAndGrantType() { ObjectNode confidential = mapper.createObjectNode(); From 681274475481d3579ee95c97966988c25e9db5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 21:55:07 -0400 Subject: [PATCH 85/99] feat(ssooidc): create IAM-authenticated tokens --- .../java/com/floci/test/TestFixtures.java | 1 + .../test/java/com/floci/test/SsoOidcTest.java | 96 +++++++ docs/services/index.md | 2 +- docs/services/ssooidc.md | 3 + .../services/ssoadmin/SsoAdminService.java | 105 +++++++ .../services/ssooidc/SsoOidcController.java | 261 +++++++++++++++++- .../services/ssooidc/SsoOidcService.java | 122 +++++++- .../ssoadmin/SsoAdminServiceTest.java | 26 ++ .../ssooidc/SsoOidcIntegrationTest.java | 73 +++++ .../services/ssooidc/SsoOidcServiceTest.java | 33 +++ 10 files changed, 704 insertions(+), 18 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index 06aea66d7a..18ac0bd67c 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java @@ -330,6 +330,7 @@ public static SsoOidcClient ssoOidcClient() { return SsoOidcClient.builder() .endpointOverride(ENDPOINT) .region(REGION) + .credentialsProvider(CREDENTIALS) .build(); } diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index 420b567948..cc313eed66 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.document.Document; +import software.amazon.awssdk.services.ssoadmin.SsoAdminClient; import software.amazon.awssdk.services.ssooidc.SsoOidcClient; import static org.assertj.core.api.Assertions.assertThat; @@ -83,4 +85,98 @@ void startDeviceAuthorizationUsesAwsSdk() { assertThat(refreshed.accessToken()).isNotEqualTo(token.accessToken()); } } + + @Test + @DisplayName("creates IAM-authenticated tokens through the AWS SDK") + void createTokenWithIamUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator IAM application configuration"); + + try (SsoAdminClient sso = TestFixtures.ssoAdminClient(); + SsoOidcClient oidc = TestFixtures.ssoOidcClient()) { + String instanceArn = sso.listInstances(request -> {}).instances().get(0).instanceArn(); + String applicationArn = sso.createApplication(request -> request + .instanceArn(instanceArn) + .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom") + .name("Floci IAM OIDC SDK")) + .applicationArn(); + + Document statement = Document.mapBuilder() + .putString("Effect", "Allow") + .putDocument("Principal", Document.fromString("*")) + .putString("Action", "sso-oauth:CreateTokenWithIAM") + .putString("Resource", "*") + .build(); + Document actorPolicy = Document.mapBuilder() + .putString("Version", "2012-10-17") + .putDocument("Statement", Document.fromList(java.util.List.of(statement))) + .build(); + sso.putApplicationAuthenticationMethod(request -> request + .applicationArn(applicationArn) + .authenticationMethodType("IAM") + .authenticationMethod(method -> method.iam(iam -> iam.actorPolicy(actorPolicy)))); + String redirectUri = "http://127.0.0.1:8400/callback"; + sso.putApplicationGrant(request -> request + .applicationArn(applicationArn) + .grantType("authorization_code") + .grant(grant -> grant.authorizationCode(code -> code.redirectUris(redirectUri)))); + sso.putApplicationAccessScope(request -> request + .applicationArn(applicationArn) + .scope("api:read") + .authorizedTargets(applicationArn)); + + String verifier = "01234567890123456789012345678901234567890123456789"; + String challenge = pkceChallenge(verifier); + String authorizeUrl = TestFixtures.endpoint().toString() + + "/authorize?response_type=code&client_id=" + + java.net.URLEncoder.encode(applicationArn, java.nio.charset.StandardCharsets.UTF_8) + + "&redirect_uri=" + java.net.URLEncoder.encode(redirectUri, java.nio.charset.StandardCharsets.UTF_8) + + "&code_challenge=" + java.net.URLEncoder.encode(challenge, java.nio.charset.StandardCharsets.UTF_8) + + "&code_challenge_method=S256"; + String code; + try { + var browserResponse = java.net.http.HttpClient.newBuilder() + .followRedirects(java.net.http.HttpClient.Redirect.NEVER) + .build() + .send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(authorizeUrl)).GET().build(), + java.net.http.HttpResponse.BodyHandlers.ofString()); + assertThat(browserResponse.statusCode()).isEqualTo(303); + var params = java.net.URI.create(browserResponse.headers().firstValue("location").orElseThrow()) + .getRawQuery().split("&"); + code = java.util.Arrays.stream(params) + .filter(value -> value.startsWith("code=")) + .map(value -> java.net.URLDecoder.decode(value.substring(5), java.nio.charset.StandardCharsets.UTF_8)) + .findFirst().orElseThrow(); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + var response = oidc.createTokenWithIam(request -> request + .clientId(applicationArn) + .grantType("authorization_code") + .code(code) + .codeVerifier(verifier) + .redirectUri(redirectUri) + .scope("api:read")); + + assertThat(response.tokenType()).isEqualTo("Bearer"); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.refreshToken()).isNotBlank(); + assertThat(response.idToken()).isNotBlank(); + assertThat(response.scope()).containsExactly("api:read"); + assertThat(response.awsAdditionalDetails().identityContext()).isNotBlank(); + } + } + + private static String pkceChallenge(String verifier) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(verifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } } diff --git a/docs/services/index.md b/docs/services/index.md index 283f60f02e..b88cc90635 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -47,7 +47,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [AWS Account Management](account.md) | `/putAlternateContact`, `/getAlternateContact` | REST JSON | 2 | | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | -| [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/authorize`, `/device` | REST JSON | 3 | +| [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssooidc.md b/docs/services/ssooidc.md index ae86e97e8d..448ae29b96 100644 --- a/docs/services/ssooidc.md +++ b/docs/services/ssooidc.md @@ -14,6 +14,7 @@ Floci emulates the IAM Identity Center OIDC registration and token endpoints use | `RegisterClient` | Registers a public OIDC client, persists its generated client credentials, and returns local authorization and token endpoints. | | `StartDeviceAuthorization` | Validates a registered public client and creates a persisted short-lived device authorization challenge. | | `CreateToken` | Exchanges device, PKCE authorization-code, or refresh-token grants for persisted bearer and refresh tokens. | +| `CreateTokenWithIAM` | Uses SigV4 and an application's IAM ActorPolicy to issue downscoped tokens for authorization-code, refresh-token, JWT-bearer, and token-exchange grants. | `RegisterClient` supports the authorization-code, device-code, and refresh-token grant identifiers documented by AWS. Client registrations are persisted so later device authorization and token operations can authenticate the generated client ID and secret. @@ -22,6 +23,8 @@ Floci emulates the IAM Identity Center OIDC registration and token endpoints use `CreateToken` supports all three AWS-documented public-client grants. Device authorization can be completed locally by visiting the returned `/device?user_code=...` URL. Authorization Code uses the local `/authorize` endpoint with PKCE S256 and registered redirect URIs. Floci issues one-hour access tokens and 30-day refresh tokens as emulator defaults; the public API documentation does not define fixed lifetimes for these values. The `scope` request is intentionally ignored because AWS states that this operation always grants the scopes configured during client registration. +`CreateTokenWithIAM` requires SigV4 and reads the application's IAM authentication method, OAuth grants, and access scopes from the SSO Admin service. ActorPolicy evaluation honors explicit deny and supports wildcard or account/root principals that Floci can resolve from the signing credential. Role- and user-specific principal ARNs are denied because the current request context retains account and Region but not the exact signing principal ARN. JWT Bearer validates JWT structure, expiration, issuer, and audience against the configured trusted token issuer and grant; it does not perform external JWKS signature verification in the local emulator. Token Exchange requires a token issued to a different IAM application and an authorized target on one of the subject token's scopes. + OIDC failures use the AWS response shape with `error` and `error_description`. The local emulator assigns a 90-day client-secret lifetime; AWS documents the expiration timestamp but does not publish a fixed lifetime for this operation. See the [IAM Identity Center OIDC API Reference](https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 331733601d..a41556622e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -864,6 +864,111 @@ public ApplicationAccessScope getApplicationAccessScope(JsonNode request) { .orElseThrow(() -> notFound("Application access scope not found: " + scope)); } + public SsoApplication applicationForOidc(String applicationArn) { + return getApplication(applicationArn); + } + + public ApplicationGrant applicationGrantForOidc(String applicationArn, String grantType) { + validateApplicationArn(applicationArn); + getApplication(applicationArn); + return applicationGrants.get(applicationGrantKey(applicationArn, grantType)) + .orElseThrow(() -> notFound("Application grant not found: " + grantType)); + } + + public List applicationAccessScopesForOidc(String applicationArn) { + validateApplicationArn(applicationArn); + getApplication(applicationArn); + return applicationAccessScopes.scan(key -> true).stream() + .filter(scope -> applicationArn.equals(scope.applicationArn())) + .sorted(Comparator.comparing(ApplicationAccessScope::scope)) + .toList(); + } + + public boolean iamActorPolicyAllows(String applicationArn, String callerAccountId) { + validateApplicationArn(applicationArn); + getApplication(applicationArn); + ApplicationAuthenticationMethod method = applicationAuthenticationMethods.get( + applicationAuthenticationMethodKey(applicationArn, "IAM")) + .orElse(null); + if (method == null || method.authenticationMethod() == null) { + return false; + } + JsonNode policy = method.authenticationMethod().path("Iam").path("ActorPolicy"); + if (!policy.isObject() || !"2012-10-17".equals(policy.path("Version").asText())) { + return false; + } + JsonNode statements = policy.get("Statement"); + if (statements == null) { + return false; + } + List statementList = new ArrayList<>(); + if (statements.isArray()) { + statements.forEach(statementList::add); + } else if (statements.isObject()) { + statementList.add(statements); + } else { + return false; + } + boolean allowed = false; + for (JsonNode statement : statementList) { + if (!statement.isObject() || !policyActionMatches(statement.get("Action")) + || !policyResourceMatches(statement.get("Resource")) + || !policyPrincipalMatches(statement.get("Principal"), callerAccountId)) { + continue; + } + if ("Deny".equals(statement.path("Effect").asText())) { + return false; + } + if ("Allow".equals(statement.path("Effect").asText())) { + allowed = true; + } + } + return allowed; + } + + private static boolean policyActionMatches(JsonNode action) { + return policyStringOrArrayMatches(action, value -> "*".equals(value) + || "sso-oauth:*".equalsIgnoreCase(value) + || "sso-oauth:CreateTokenWithIAM".equalsIgnoreCase(value)); + } + + private static boolean policyResourceMatches(JsonNode resource) { + return policyStringOrArrayMatches(resource, "*"::equals); + } + + private static boolean policyPrincipalMatches(JsonNode principal, String accountId) { + if (principal == null || accountId == null) { + return false; + } + if (principal.isTextual()) { + return "*".equals(principal.textValue()); + } + if (!principal.isObject()) { + return false; + } + JsonNode aws = principal.get("AWS"); + Pattern rootArn = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:iam::" + accountId + ":root"); + return policyStringOrArrayMatches(aws, value -> "*".equals(value) + || accountId.equals(value) || rootArn.matcher(value).matches()); + } + + private static boolean policyStringOrArrayMatches(JsonNode node, java.util.function.Predicate predicate) { + if (node == null) { + return false; + } + if (node.isTextual()) { + return predicate.test(node.textValue()); + } + if (node.isArray()) { + for (JsonNode value : node) { + if (value.isTextual() && predicate.test(value.textValue())) { + return true; + } + } + } + return false; + } + public PaginatedResult listApplicationAccessScopes(JsonNode request) { String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); getApplication(applicationArn); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java index 8237caf5ee..08073e6fdb 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -7,11 +7,16 @@ import io.github.hectorvent.floci.services.ssooidc.model.AuthorizationCode; import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import io.github.hectorvent.floci.core.common.RequestContext; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; +import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationGrant; import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; @@ -22,17 +27,34 @@ import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; @ApplicationScoped @Path("/") @Produces(MediaType.APPLICATION_JSON) public class SsoOidcController { + private static final Set IAM_GRANT_TYPES = Set.of( + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "urn:ietf:params:oauth:grant-type:token-exchange"); + private final SsoOidcService service; + private final SsoAdminService ssoAdminService; + private final RequestContext requestContext; private final ObjectMapper objectMapper; @Inject - public SsoOidcController(SsoOidcService service, ObjectMapper objectMapper) { + public SsoOidcController(SsoOidcService service, SsoAdminService ssoAdminService, + RequestContext requestContext, ObjectMapper objectMapper) { this.service = service; + this.ssoAdminService = ssoAdminService; + this.requestContext = requestContext; this.objectMapper = objectMapper; } @@ -82,16 +104,16 @@ public Response startDeviceAuthorization(String body) { @POST @Path("/token") @Consumes(MediaType.APPLICATION_JSON) - public Response createToken(String body) { + public Response createToken(String body, + @QueryParam("aws_iam") String awsIam, + @HeaderParam("Authorization") String authorizationHeader) { try { - TokenSession session = service.createToken(readTree(body)); - ObjectNode response = objectMapper.createObjectNode(); - response.put("accessToken", session.accessToken()); - response.put("refreshToken", session.refreshToken()); - response.put("expiresIn", (int) (session.accessTokenExpiresAtEpochSeconds() - - System.currentTimeMillis() / 1000L)); - response.put("tokenType", "Bearer"); - return Response.ok(response).build(); + JsonNode request = readTree(body); + if ("t".equals(awsIam)) { + return createTokenWithIam(request, authorizationHeader); + } + TokenSession session = service.createToken(request); + return tokenResponse(session, null, null, null); } catch (SsoOidcException e) { return oidcError(e.status(), e.error(), e.getMessage()); } catch (RuntimeException e) { @@ -99,6 +121,214 @@ public Response createToken(String body) { } } + private Response createTokenWithIam(JsonNode request, String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("AWS4-HMAC-SHA256 ")) { + throw new SsoOidcException("access_denied", "CreateTokenWithIAM requires Signature Version 4", 400); + } + String applicationArn = requiredText(request, "clientId"); + String grantType = requiredText(request, "grantType"); + if (!IAM_GRANT_TYPES.contains(grantType)) { + throw new SsoOidcException("unsupported_grant_type", "Unsupported grant type: " + grantType, 400); + } + try { + ssoAdminService.applicationForOidc(applicationArn); + } catch (RuntimeException e) { + throw new SsoOidcException("invalid_client", "Application is invalid", 401); + } + if (!ssoAdminService.iamActorPolicyAllows(applicationArn, requestContext.getAccountId())) { + throw new SsoOidcException("access_denied", "The IAM principal is not authorized by the application policy", 400); + } + + ApplicationGrant grant; + try { + grant = ssoAdminService.applicationGrantForOidc(applicationArn, grantType); + } catch (RuntimeException e) { + throw new SsoOidcException("unauthorized_client", "The application is not configured for this grant", 400); + } + List scopes; + if ("refresh_token".equals(grantType) && (request.get("scope") == null || request.get("scope").isNull())) { + scopes = service.requireRefreshToken(applicationArn, requiredText(request, "refreshToken")).scopes(); + } else { + scopes = resolveIamScopes(applicationArn, request); + } + if ("urn:ietf:params:oauth:grant-type:jwt-bearer".equals(grantType)) { + validateJwtBearerGrant(request, grant); + } + if ("urn:ietf:params:oauth:grant-type:token-exchange".equals(grantType)) { + validateTokenExchangeTarget(request, applicationArn); + } + TokenSession session = service.createIamToken(request, applicationArn, scopes); + String issuedTokenType = "urn:ietf:params:oauth:token-type:access_token"; + if ("urn:ietf:params:oauth:grant-type:token-exchange".equals(grantType)) { + String requested = SsoOidcService.optionalText(request, "requestedTokenType"); + if (requested != null) { + issuedTokenType = requested; + } + } + String idToken = localIdToken(applicationArn, requestContext.getAccountId()); + String identityContext = Base64.getUrlEncoder().withoutPadding().encodeToString( + (requestContext.getAccountId() + ":" + applicationArn).getBytes(StandardCharsets.UTF_8)); + return tokenResponse(session, scopes, issuedTokenType, new String[]{idToken, identityContext}); + } + + private Response tokenResponse(TokenSession session, List scopes, + String issuedTokenType, String[] iamDetails) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("accessToken", session.accessToken()); + if (session.refreshToken() != null) { + response.put("refreshToken", session.refreshToken()); + } + response.put("expiresIn", Math.max(0, (int) (session.accessTokenExpiresAtEpochSeconds() + - System.currentTimeMillis() / 1000L))); + response.put("tokenType", "Bearer"); + if (scopes != null) { + var scopeArray = response.putArray("scope"); + scopes.forEach(scopeArray::add); + } + if (issuedTokenType != null) { + response.put("issuedTokenType", issuedTokenType); + } + if (iamDetails != null) { + response.put("idToken", iamDetails[0]); + response.putObject("awsAdditionalDetails").put("identityContext", iamDetails[1]); + } + return Response.ok(response).build(); + } + + private List resolveIamScopes(String applicationArn, JsonNode request) { + LinkedHashSet configured = new LinkedHashSet<>(); + configured.add("openid"); + configured.add("aws"); + configured.add("sts:identity_context"); + ssoAdminService.applicationAccessScopesForOidc(applicationArn).stream() + .map(ApplicationAccessScope::scope) + .forEach(configured::add); + JsonNode requested = request.get("scope"); + if (requested == null || requested.isNull()) { + return List.copyOf(configured); + } + if (!requested.isArray()) { + throw new SsoOidcException("invalid_request", "scope must be an array", 400); + } + LinkedHashSet scopes = new LinkedHashSet<>(); + for (JsonNode scope : requested) { + if (!scope.isTextual() || !configured.contains(scope.textValue())) { + throw new SsoOidcException("invalid_scope", "Requested scope is not configured for the application", 400); + } + scopes.add(scope.textValue()); + } + return List.copyOf(scopes); + } + + private void validateJwtBearerGrant(JsonNode request, ApplicationGrant grant) { + String assertion = requiredText(request, "assertion"); + String[] parts = assertion.split("\\.", -1); + if (parts.length != 3) { + throw new SsoOidcException("invalid_grant", "assertion must be a JWT", 400); + } + JsonNode claims; + try { + claims = objectMapper.readTree(Base64.getUrlDecoder().decode(parts[1])); + } catch (Exception e) { + throw new SsoOidcException("invalid_grant", "assertion JWT payload is invalid", 400); + } + if (claims.has("exp") && claims.path("exp").asLong(0) <= Instant.now().getEpochSecond()) { + throw new SsoOidcException("expired_token", "assertion has expired", 400); + } + String issuer = claims.path("iss").asText(null); + if (issuer == null) { + throw new SsoOidcException("invalid_grant", "assertion must contain iss", 400); + } + Set audiences = jwtAudiences(claims.get("aud")); + JsonNode configuredIssuers = grant.grant().path("JwtBearer").path("AuthorizedTokenIssuers"); + boolean authorized = false; + for (JsonNode configuredIssuer : configuredIssuers) { + String issuerArn = configuredIssuer.path("TrustedTokenIssuerArn").asText(null); + if (issuerArn == null) { + continue; + } + try { + var trusted = ssoAdminService.getTrustedTokenIssuer(issuerArn); + if (!issuer.equals(trusted.oidcJwtConfiguration().issuerUrl())) { + continue; + } + JsonNode configuredAudiences = configuredIssuer.get("AuthorizedAudiences"); + if (configuredAudiences == null || configuredAudiences.isNull()) { + authorized = true; + break; + } + for (JsonNode audience : configuredAudiences) { + if (audience.isTextual() && audiences.contains(audience.textValue())) { + authorized = true; + break; + } + } + if (authorized) { + break; + } + } catch (RuntimeException ignored) { + // A missing configured issuer cannot authorize this assertion. + } + } + if (!authorized) { + throw new SsoOidcException("invalid_grant", "assertion issuer or audience is not authorized", 400); + } + } + + private static Set jwtAudiences(JsonNode aud) { + LinkedHashSet result = new LinkedHashSet<>(); + if (aud == null) { + return result; + } + if (aud.isTextual()) { + result.add(aud.textValue()); + } else if (aud.isArray()) { + for (JsonNode value : aud) { + if (value.isTextual()) { + result.add(value.textValue()); + } + } + } + return result; + } + + private void validateTokenExchangeTarget(JsonNode request, String targetApplicationArn) { + String subjectToken = requiredText(request, "subjectToken"); + TokenSession subject = service.requireAccessToken(subjectToken); + if (targetApplicationArn.equals(subject.clientId()) || !subject.clientId().startsWith("arn:")) { + throw new SsoOidcException("invalid_grant", "Subject token must come from a different IAM application", 400); + } + boolean authorizedTarget = ssoAdminService.applicationAccessScopesForOidc(subject.clientId()).stream() + .filter(scope -> subject.scopes().contains(scope.scope())) + .anyMatch(scope -> scope.authorizedTargets().contains(targetApplicationArn)); + if (!authorizedTarget) { + throw new SsoOidcException("invalid_grant", "Subject token does not authorize the requested application", 400); + } + } + + private String localIdToken(String applicationArn, String accountId) { + ObjectNode header = objectMapper.createObjectNode().put("alg", "none").put("typ", "JWT"); + long now = Instant.now().getEpochSecond(); + ObjectNode claims = objectMapper.createObjectNode() + .put("sub", accountId) + .put("aud", applicationArn) + .put("iat", now) + .put("exp", now + 3600); + return base64Url(header.toString()) + "." + base64Url(claims.toString()) + "."; + } + + private static String base64Url(String value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String requiredText(JsonNode request, String field) { + JsonNode value = request == null ? null : request.get(field); + if (value == null || !value.isTextual() || value.textValue().isBlank()) { + throw new SsoOidcException("invalid_request", field + " is required", 400); + } + return value.textValue(); + } + @GET @Path("/device") public Response authorizeDevice(@QueryParam("user_code") String userCode) { @@ -127,7 +357,16 @@ public Response authorizeCode(@QueryParam("response_type") String responseType, if (!"S256".equals(codeChallengeMethod)) { throw new SsoOidcException("invalid_request", "code_challenge_method must be S256", 400); } - AuthorizationCode authorization = service.createAuthorizationCode(clientId, redirectUri, codeChallenge); + AuthorizationCode authorization; + if (clientId != null && clientId.startsWith("arn:")) { + ApplicationGrant grant = ssoAdminService.applicationGrantForOidc(clientId, "authorization_code"); + List redirects = new ArrayList<>(); + grant.grant().path("AuthorizationCode").path("RedirectUris") + .forEach(uri -> { if (uri.isTextual()) redirects.add(uri.textValue()); }); + authorization = service.createIamAuthorizationCode(clientId, redirectUri, codeChallenge, redirects); + } else { + authorization = service.createAuthorizationCode(clientId, redirectUri, codeChallenge); + } String separator = redirectUri.contains("?") ? "&" : "?"; String location = redirectUri + separator + "code=" + URLEncoder.encode(authorization.code(), StandardCharsets.UTF_8); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index 09e22ee8e8..d4c5167245 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -179,7 +179,17 @@ public synchronized AuthorizationCode createAuthorizationCode( if (!client.grantTypes().isEmpty() && !client.grantTypes().contains("authorization_code")) { throw new SsoOidcException("unauthorized_client", "Client is not registered for authorization code", 400); } - if (redirectUri == null || redirectUri.isBlank() || !client.redirectUris().contains(redirectUri)) { + return createAuthorizationCode(clientId, redirectUri, codeChallenge, client.redirectUris()); + } + + public synchronized AuthorizationCode createIamAuthorizationCode( + String applicationArn, String redirectUri, String codeChallenge, List allowedRedirectUris) { + return createAuthorizationCode(applicationArn, redirectUri, codeChallenge, allowedRedirectUris); + } + + private AuthorizationCode createAuthorizationCode( + String clientId, String redirectUri, String codeChallenge, List allowedRedirectUris) { + if (redirectUri == null || redirectUri.isBlank() || !allowedRedirectUris.contains(redirectUri)) { throw new SsoOidcException("invalid_redirect_uri", "Redirect URI is not registered", 400); } if (codeChallenge == null || codeChallenge.isBlank()) { @@ -273,14 +283,114 @@ private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredCli } private TokenSession issueToken(RegisteredClient client) { + return issueToken(client.clientId(), client.scopes(), true); + } + + public synchronized TokenSession createIamToken(JsonNode request, String applicationArn, List grantedScopes) { + String grantType = requiredText(request, "grantType"); + return switch (grantType) { + case "authorization_code" -> createIamTokenFromAuthorizationCode(request, applicationArn, grantedScopes); + case "refresh_token" -> createIamTokenFromRefreshToken(request, applicationArn, grantedScopes); + case "urn:ietf:params:oauth:grant-type:jwt-bearer" -> { + requiredText(request, "assertion"); + yield issueToken(applicationArn, grantedScopes, true); + } + case "urn:ietf:params:oauth:grant-type:token-exchange" -> + createIamTokenFromExchange(request, applicationArn, grantedScopes); + default -> throw new SsoOidcException("unsupported_grant_type", "Unsupported grant type: " + grantType, 400); + }; + } + + private TokenSession createIamTokenFromAuthorizationCode( + JsonNode request, String applicationArn, List grantedScopes) { + String code = requiredText(request, "code"); + String codeVerifier = requiredText(request, "codeVerifier"); + String redirectUri = requiredText(request, "redirectUri"); + AuthorizationCode authorizationCode = authorizationCodes.get(code) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Authorization code is invalid", 400)); + if (authorizationCode.expiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + authorizationCodes.delete(code); + throw new SsoOidcException("expired_token", "Authorization code has expired", 400); + } + if (!applicationArn.equals(authorizationCode.clientId()) + || !redirectUri.equals(authorizationCode.redirectUri()) + || !pkceChallenge(codeVerifier).equals(authorizationCode.codeChallenge())) { + throw new SsoOidcException("invalid_grant", "Authorization code validation failed", 400); + } + authorizationCodes.delete(code); + return issueToken(applicationArn, grantedScopes, true); + } + + private TokenSession createIamTokenFromRefreshToken( + JsonNode request, String applicationArn, List grantedScopes) { + String refreshToken = requiredText(request, "refreshToken"); + TokenSession prior = requireRefreshToken(applicationArn, refreshToken); + if (!prior.scopes().containsAll(grantedScopes)) { + throw new SsoOidcException("invalid_scope", "Requested scopes exceed the refresh token scopes", 400); + } + return issueToken(applicationArn, grantedScopes, true); + } + + private TokenSession createIamTokenFromExchange( + JsonNode request, String applicationArn, List grantedScopes) { + String subjectToken = requiredText(request, "subjectToken"); + String subjectTokenType = requiredText(request, "subjectTokenType"); + if (!"urn:ietf:params:oauth:token-type:access_token".equals(subjectTokenType)) { + throw new SsoOidcException("invalid_request", "subjectTokenType must be access_token", 400); + } + String requestedTokenType = optionalText(request, "requestedTokenType"); + if (requestedTokenType != null + && !Set.of("urn:ietf:params:oauth:token-type:access_token", + "urn:ietf:params:oauth:token-type:refresh_token").contains(requestedTokenType)) { + throw new SsoOidcException("invalid_request", "requestedTokenType is invalid", 400); + } + TokenSession subject = requireAccessToken(subjectToken); + if (applicationArn.equals(subject.clientId())) { + throw new SsoOidcException("invalid_grant", "Subject token must be issued to a different application", 400); + } + return issueToken(applicationArn, grantedScopes, + !"urn:ietf:params:oauth:token-type:access_token".equals(requestedTokenType)); + } + + public TokenSession requireRefreshToken(String applicationArn, String refreshToken) { + TokenSession prior = tokenSessions.get("refresh:" + refreshToken) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Refresh token is invalid", 400)); + if (!applicationArn.equals(prior.clientId())) { + throw new SsoOidcException("invalid_grant", "Refresh token belongs to another application", 400); + } + if (prior.refreshTokenExpiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + tokenSessions.delete("refresh:" + refreshToken); + throw new SsoOidcException("expired_token", "Refresh token has expired", 400); + } + return prior; + } + + public TokenSession requireAccessToken(String accessToken) { + TokenSession session = tokenSessions.get("access:" + accessToken) + .orElseThrow(() -> new SsoOidcException("invalid_grant", "Access token is invalid", 400)); + if (session.accessTokenExpiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { + tokenSessions.delete("access:" + accessToken); + throw new SsoOidcException("expired_token", "Access token has expired", 400); + } + return session; + } + + public TokenSession issueIamToken(String applicationArn, List scopes, boolean issueRefreshToken) { + return issueToken(applicationArn, scopes, issueRefreshToken); + } + + private TokenSession issueToken(String clientId, List scopes, boolean issueRefreshToken) { long now = System.currentTimeMillis() / 1000L; String accessToken = randomToken(); - String refreshToken = randomToken(); + String refreshToken = issueRefreshToken ? randomToken() : null; TokenSession session = new TokenSession( - accessToken, refreshToken, client.clientId(), client.scopes(), - now + ACCESS_TOKEN_LIFETIME_SECONDS, now + REFRESH_TOKEN_LIFETIME_SECONDS); + accessToken, refreshToken, clientId, scopes, + now + ACCESS_TOKEN_LIFETIME_SECONDS, + issueRefreshToken ? now + REFRESH_TOKEN_LIFETIME_SECONDS : 0L); tokenSessions.put("access:" + accessToken, session); - tokenSessions.put("refresh:" + refreshToken, session); + if (refreshToken != null) { + tokenSessions.put("refresh:" + refreshToken, session); + } return session; } @@ -342,7 +452,7 @@ private static String requiredText(JsonNode request, String field) { return value; } - private static String optionalText(JsonNode request, String field) { + static String optionalText(JsonNode request, String field) { if (request == null || !request.has(field) || request.get(field).isNull()) { return null; } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 44caf4b181..bc570eae0d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -715,6 +715,32 @@ void putApplicationAuthenticationMethodCreatesAndUpdatesIamActorPolicy() { assertError("ValidationException", () -> service.putApplicationAuthenticationMethod(wrongUnion)); } + @Test + void iamActorPolicyAllowsWildcardAndAccountRootWithExplicitDenyPrecedence() { + SsoApplication application = createApplication("Actor Policy App", "actor-policy-app-token"); + String key = SsoAdminService.applicationAuthenticationMethodKey(application.applicationArn(), "IAM"); + ObjectNode method = mapper.createObjectNode(); + ObjectNode policy = method.putObject("Iam").putObject("ActorPolicy"); + policy.put("Version", "2012-10-17"); + ObjectNode allow = policy.putArray("Statement").addObject(); + allow.put("Effect", "Allow"); + allow.put("Principal", "*"); + allow.put("Action", "sso-oauth:CreateTokenWithIAM"); + allow.put("Resource", "*"); + applicationAuthenticationMethods.put(key, + new ApplicationAuthenticationMethod(application.applicationArn(), "IAM", method)); + assertTrue(service.iamActorPolicyAllows(application.applicationArn(), ACCOUNT_ID)); + + ObjectNode deny = policy.withArray("Statement").addObject(); + deny.put("Effect", "Deny"); + deny.putObject("Principal").put("AWS", "arn:aws:iam::" + ACCOUNT_ID + ":root"); + deny.put("Action", "sso-oauth:CreateTokenWithIAM"); + deny.put("Resource", "*"); + applicationAuthenticationMethods.put(key, + new ApplicationAuthenticationMethod(application.applicationArn(), "IAM", method.deepCopy())); + assertFalse(service.iamActorPolicyAllows(application.applicationArn(), ACCOUNT_ID)); + assertFalse(service.iamActorPolicyAllows(application.applicationArn(), "999999999999")); + } @Test void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { SsoApplication application = createApplication("Authentication App", "authentication-app-token"); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java index fc6751735b..098443fb0c 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -1,6 +1,9 @@ package io.github.hectorvent.floci.services.ssooidc; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; @@ -10,6 +13,17 @@ @QuarkusTest class SsoOidcIntegrationTest { + private static final String AUTH_HEADER = + "AWS4-HMAC-SHA256 Credential=AKID/20260101/us-east-1/sso-oauth/aws4_request"; + + @Inject + SsoAdminService ssoAdminService; + + @Inject + SsoOidcService ssoOidcService; + + @Inject + ObjectMapper mapper; @Test void registerClientReturnsAwsOidcShapeWithoutSigV4() { @@ -108,6 +122,65 @@ void createTokenCompletesDeviceFlowAndRefreshesToken() { .body("tokenType", equalTo("Bearer")); } + @Test + void createTokenWithIamRequiresSigV4AndUsesApplicationPolicy() { + String instanceArn = ssoAdminService.getInstanceArn(); + var create = mapper.createObjectNode(); + create.put("InstanceArn", instanceArn); + create.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + create.put("Name", "IAM OIDC Integration"); + String applicationArn = ssoAdminService.createApplication(create, "000000000000", "us-east-1").applicationArn(); + + var authentication = mapper.createObjectNode(); + authentication.put("ApplicationArn", applicationArn); + authentication.put("AuthenticationMethodType", "IAM"); + var policy = authentication.putObject("AuthenticationMethod").putObject("Iam").putObject("ActorPolicy"); + policy.put("Version", "2012-10-17"); + var statement = policy.putArray("Statement").addObject(); + statement.put("Effect", "Allow"); + statement.put("Principal", "*"); + statement.put("Action", "sso-oauth:CreateTokenWithIAM"); + statement.put("Resource", "*"); + ssoAdminService.putApplicationAuthenticationMethod(authentication); + + String redirectUri = "http://127.0.0.1:8400/iam-callback"; + var grant = mapper.createObjectNode(); + grant.put("ApplicationArn", applicationArn); + grant.put("GrantType", "authorization_code"); + grant.putObject("Grant").putObject("AuthorizationCode").putArray("RedirectUris").add(redirectUri); + ssoAdminService.putApplicationGrant(grant); + + String verifier = "01234567890123456789012345678901234567890123456789"; + String challenge = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(sha256(verifier)); + String code = ssoOidcService.createIamAuthorizationCode(applicationArn, redirectUri, challenge, + java.util.List.of(redirectUri)).code(); + String body = "{\"clientId\":\"" + applicationArn + "\",\"grantType\":\"authorization_code\"," + + "\"code\":\"" + code + "\",\"codeVerifier\":\"" + verifier + "\"," + + "\"redirectUri\":\"" + redirectUri + "\",\"scope\":[\"openid\"]}"; + + given().contentType("application/json").body(body) + .when().post("/token?aws_iam=t") + .then().statusCode(400).body("error", equalTo("access_denied")); + + given().contentType("application/json").header("Authorization", AUTH_HEADER).body(body) + .when().post("/token?aws_iam=t") + .then().statusCode(200) + .body("tokenType", equalTo("Bearer")) + .body("scope[0]", equalTo("openid")) + .body("accessToken", matchesPattern("[0-9a-f]{64}")) + .body("idToken", org.hamcrest.Matchers.not(org.hamcrest.Matchers.blankOrNullString())) + .body("awsAdditionalDetails.identityContext", org.hamcrest.Matchers.not(org.hamcrest.Matchers.blankOrNullString())); + } + + private static byte[] sha256(String value) { + try { + return java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + @Test void registerClientReturnsOidcErrorShape() { given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java index 2d34b76b47..2065334b54 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -135,6 +136,38 @@ void createTokenSupportsAuthorizationCodePkce() { assertOidcError("invalid_grant", () -> service.createToken(token)); } + @Test + void createIamTokenSupportsRefreshJwtBearerAndTokenExchangeBranches() { + String sourceApplication = "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-1111111111111111"; + String targetApplication = "arn:aws:sso::123456789012:application/ssoins-7223b02a5d9f7c8e/apl-2222222222222222"; + var source = service.issueIamToken(sourceApplication, java.util.List.of("api:read"), true); + + ObjectNode refresh = mapper.createObjectNode(); + refresh.put("grantType", "refresh_token"); + refresh.put("refreshToken", source.refreshToken()); + assertEquals(sourceApplication, + service.createIamToken(refresh, sourceApplication, java.util.List.of("api:read")).clientId()); + assertOidcError("invalid_scope", + () -> service.createIamToken(refresh, sourceApplication, java.util.List.of("api:write"))); + + ObjectNode jwt = mapper.createObjectNode(); + jwt.put("grantType", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + jwt.put("assertion", "header.payload.signature"); + assertEquals(targetApplication, + service.createIamToken(jwt, targetApplication, java.util.List.of("openid")).clientId()); + + ObjectNode exchange = mapper.createObjectNode(); + exchange.put("grantType", "urn:ietf:params:oauth:grant-type:token-exchange"); + exchange.put("subjectToken", source.accessToken()); + exchange.put("subjectTokenType", "urn:ietf:params:oauth:token-type:access_token"); + exchange.put("requestedTokenType", "urn:ietf:params:oauth:token-type:access_token"); + var exchanged = service.createIamToken(exchange, targetApplication, java.util.List.of("openid")); + assertEquals(targetApplication, exchanged.clientId()); + assertNull(exchanged.refreshToken()); + + assertOidcError("invalid_grant", + () -> service.createIamToken(exchange, sourceApplication, java.util.List.of("openid"))); + } private static byte[] sha256(String value) { try { return java.security.MessageDigest.getInstance("SHA-256") From 2957d2c1024f3432054b8196e68949ec8b236481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 22:27:50 -0400 Subject: [PATCH 86/99] feat(ssoportal): list assigned accounts --- .../java/com/floci/test/TestFixtures.java | 9 ++ .../java/com/floci/test/SsoPortalTest.java | 101 ++++++++++++++ docs/services/index.md | 1 + docs/services/ssoportal.md | 29 ++++ .../core/common/ResolvedServiceCatalog.java | 5 +- .../organizations/OrganizationsService.java | 9 ++ .../services/ssoadmin/SsoAdminService.java | 44 ++++++ .../services/ssooidc/SsoOidcController.java | 15 ++- .../services/ssooidc/SsoOidcService.java | 51 ++++--- .../ssooidc/model/AuthorizationCode.java | 3 +- .../ssooidc/model/DeviceAuthorization.java | 3 +- .../services/ssooidc/model/TokenSession.java | 3 +- .../ssoportal/SsoPortalController.java | 52 ++++++++ .../services/ssoportal/SsoPortalService.java | 71 ++++++++++ .../ssoportal/model/PortalAccountInfo.java | 6 + .../services/ssooidc/SsoOidcServiceTest.java | 2 +- .../ssoportal/SsoPortalIntegrationTest.java | 126 ++++++++++++++++++ 17 files changed, 499 insertions(+), 31 deletions(-) create mode 100644 compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java create mode 100644 docs/services/ssoportal.md create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalAccountInfo.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index 18ac0bd67c..9823096044 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java @@ -33,6 +33,7 @@ import software.amazon.awssdk.services.guardduty.GuardDutyClient; import software.amazon.awssdk.services.fis.FisClient; import software.amazon.awssdk.services.organizations.OrganizationsClient; +import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.ssoadmin.SsoAdminClient; import software.amazon.awssdk.services.ssooidc.SsoOidcClient; import software.amazon.awssdk.services.identitystore.IdentitystoreClient; @@ -334,6 +335,14 @@ public static SsoOidcClient ssoOidcClient() { .build(); } + public static SsoClient ssoPortalClient() { + return SsoClient.builder() + .endpointOverride(ENDPOINT) + .region(REGION) + .credentialsProvider(CREDENTIALS) + .build(); + } + public static IdentitystoreClient identityStoreClient() { return IdentitystoreClient.builder() .endpointOverride(ENDPOINT) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java new file mode 100644 index 0000000000..27fa2246d8 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -0,0 +1,101 @@ +package com.floci.test; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.identitystore.IdentitystoreClient; +import software.amazon.awssdk.services.identitystore.model.CreateUserRequest; +import software.amazon.awssdk.services.sso.SsoClient; +import software.amazon.awssdk.services.ssoadmin.SsoAdminClient; +import software.amazon.awssdk.services.ssoadmin.model.PrincipalType; +import software.amazon.awssdk.services.ssoadmin.model.TargetType; +import software.amazon.awssdk.services.ssooidc.SsoOidcClient; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +@DisplayName("IAM Identity Center access portal") +class SsoPortalTest { + + @Test + @DisplayName("lists accounts assigned to the authenticated user through the AWS SDK") + void listAccountsUsesAwsSdk() { + assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only local sign-in completion"); + + try (SsoAdminClient admin = TestFixtures.ssoAdminClient(); + IdentitystoreClient identityStore = TestFixtures.identityStoreClient(); + SsoOidcClient oidc = TestFixtures.ssoOidcClient(); + SsoClient portal = TestFixtures.ssoPortalClient()) { + var instance = admin.listInstances(request -> {}).instances().get(0); + String suffix = UUID.randomUUID().toString().substring(0, 8); + String userId = identityStore.createUser(CreateUserRequest.builder() + .identityStoreId(instance.identityStoreId()) + .userName("portal-sdk-" + suffix + "@example.com") + .displayName("Portal SDK " + suffix) + .build()).userId(); + String permissionSetArn = admin.createPermissionSet(request -> request + .instanceArn(instance.instanceArn()) + .name("PortalSdk" + suffix)) + .permissionSet().permissionSetArn(); + String accountId = "111111111111"; + admin.createAccountAssignment(request -> request + .instanceArn(instance.instanceArn()) + .targetId(accountId) + .targetType(TargetType.AWS_ACCOUNT) + .permissionSetArn(permissionSetArn) + .principalType(PrincipalType.USER) + .principalId(userId)); + + var client = oidc.registerClient(request -> request + .clientName("Portal SDK " + suffix) + .clientType("public") + .grantTypes("urn:ietf:params:oauth:grant-type:device_code") + .scopes("sso:account:access")); + var authorization = oidc.startDeviceAuthorization(request -> request + .clientId(client.clientId()) + .clientSecret(client.clientSecret()) + .startUrl("https://example.awsapps.com/start")); + authorizeDevice(authorization.verificationUriComplete(), userId); + String accessToken = oidc.createToken(request -> request + .clientId(client.clientId()) + .clientSecret(client.clientSecret()) + .grantType("urn:ietf:params:oauth:grant-type:device_code") + .deviceCode(authorization.deviceCode())) + .accessToken(); + + var response = portal.listAccounts(request -> request + .accessToken(accessToken) + .maxResults(100)); + assertThat(response.accountList()) + .extracting(software.amazon.awssdk.services.sso.model.AccountInfo::accountId) + .contains(accountId); + + assertThatThrownBy(() -> portal.listAccounts(request -> request.accessToken("invalid"))) + .isInstanceOf(software.amazon.awssdk.services.sso.model.UnauthorizedException.class); + } + } + + private static void authorizeDevice(String verificationUriComplete, String userId) { + try { + String separator = verificationUriComplete.contains("?") ? "&" : "?"; + URI uri = URI.create(verificationUriComplete + separator + "principal_id=" + + URLEncoder.encode(userId, StandardCharsets.UTF_8)); + HttpResponse response = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } +} diff --git a/docs/services/index.md b/docs/services/index.md index b88cc90635..656de5f81b 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -48,6 +48,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | | [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | +| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts` | REST JSON | 1 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssoportal.md b/docs/services/ssoportal.md new file mode 100644 index 0000000000..cf5ff81165 --- /dev/null +++ b/docs/services/ssoportal.md @@ -0,0 +1,29 @@ +# AWS IAM Identity Center Access Portal + +**Protocol:** REST JSON + +**Endpoint:** `http://localhost:4566` + +Floci emulates the IAM Identity Center access portal API used by authenticated workforce users to discover their assigned AWS accounts and roles and obtain local role credentials. + +## Supported Actions + + +| Action | Description | +| --- | --- | +| `ListAccounts` | Lists the AWS accounts effectively assigned to the user represented by the OIDC access token, including assignments inherited through Identity Store groups. | + + +`ListAccounts` accepts the bearer token in the AWS-compatible `x-amz-sso_bearer_token` header and supports the documented `max_result` and `next_token` query parameters. The access token must have been completed through Floci's local OIDC authorization flow with a user principal. Direct user assignments and group assignments are resolved from SSO Admin and Identity Store state. Account names and email addresses are populated from Organizations when that account exists in the owning organization; these fields are optional in the AWS `AccountInfo` model. + +Portal errors use the AWS service exception names such as `UnauthorizedException` and `InvalidRequestException`, rather than the OAuth error envelope used by the OIDC API. + +See the [IAM Identity Center Access Portal API Reference](https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/Welcome.html). + +## Configuration + +The access portal shares IAM Identity Center's `sso` namespace and the SSO Admin enablement flag. + +| Variable | Default | Description | +|---|---|---| +| `FLOCI_SERVICES_SSOADMIN_ENABLED` | `true` | Enable or disable the IAM Identity Center `sso` namespace, including access portal routes | diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java index 88d30ff457..091e3fc89d 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java @@ -41,6 +41,7 @@ import io.github.hectorvent.floci.services.inspector2.Inspector2Controller; import io.github.hectorvent.floci.services.securityhub.SecurityHubController; import io.github.hectorvent.floci.services.ssooidc.SsoOidcController; +import io.github.hectorvent.floci.services.ssoportal.SsoPortalController; import io.github.hectorvent.floci.services.detective.DetectiveController; import io.github.hectorvent.floci.services.aps.ApsController; import io.github.hectorvent.floci.services.controlcatalog.ControlCatalogController; @@ -425,8 +426,8 @@ public ResolvedServiceCatalog(EmulatorConfig config) { // StarlingDoveService above). descriptor("sso", "ssoadmin", config.services().ssoadmin().enabled(), true, null, null, 5000L, null, ServiceProtocol.JSON, - protocols(ServiceProtocol.JSON), - Set.of("SWBExternalService."), Set.of("sso"), Set.of(), Set.of()), + protocols(ServiceProtocol.JSON, ServiceProtocol.REST_JSON), + Set.of("SWBExternalService."), Set.of("sso"), Set.of(), Set.of(SsoPortalController.class)), descriptor("sso-oidc", "ssooidc", config.services().ssooidc().enabled(), true, "ssooidc", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON, protocols(ServiceProtocol.REST_JSON), diff --git a/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java b/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java index f1e98f98ff..066fcafdf2 100644 --- a/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java +++ b/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java @@ -541,6 +541,15 @@ public OrganizationAccount describeAccount(String callerAccountId, String accoun return requireAccount(requireOrganizationForCaller(callerAccountId), accountId); } + public java.util.Optional findAccountForPortal(String accountId) { + if (accountId == null || !ACCOUNT_ID_PATTERN.matcher(accountId).matches()) { + return java.util.Optional.empty(); + } + return accounts.scanAllAccounts().stream() + .filter(account -> accountId.equals(account.getId())) + .findFirst(); + } + public List listAccounts(String callerAccountId) { Organization organization = requireOrganizationForCaller(callerAccountId); return accountsIn(organization).stream() diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index a41556622e..0ec814345d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1729,6 +1729,50 @@ public List listAssignments(String instanceArn, String accountId, St .sorted(Comparator.comparing(Assignment::principalId)).toList(); } + public List portalAssignmentsForUser(String userId) { + validatePrincipalId(userId); + Map> groupsByIdentityStore = new java.util.HashMap<>(); + Set invalidIdentityStores = new java.util.HashSet<>(); + return assignments.scan(key -> true).stream() + .filter(assignment -> { + SsoInstance instance = requireInstance(instanceArnForPermissionSet(assignment.permissionSetArn())); + String storeId = instance.identityStoreId(); + if (invalidIdentityStores.contains(storeId)) { + return false; + } + Set groupIds; + try { + groupIds = groupsByIdentityStore.computeIfAbsent(storeId, + id -> identityStoreService.groupIdsForUser(id, userId)); + } catch (AwsException e) { + invalidIdentityStores.add(storeId); + return false; + } + if ("USER".equals(assignment.principalType())) { + return userId.equals(assignment.principalId()); + } + if (!"GROUP".equals(assignment.principalType())) { + return false; + } + return groupIds.contains(assignment.principalId()); + }) + .sorted(Comparator.comparing(Assignment::accountId) + .thenComparing(Assignment::permissionSetArn) + .thenComparing(Assignment::principalType) + .thenComparing(Assignment::principalId)) + .toList(); + } + + public SsoInstance instanceForPermissionSetForPortal(String permissionSetArn) { + validatePermissionSetArn(permissionSetArn); + return requireInstance(instanceArnForPermissionSet(permissionSetArn)); + } + + public PermissionSet permissionSetForPortal(String permissionSetArn) { + SsoInstance instance = instanceForPermissionSetForPortal(permissionSetArn); + return getPermissionSet(instance.instanceArn(), permissionSetArn); + } + public PaginatedResult listAssignmentsForPrincipal(JsonNode request, String callerAccountId) { String instanceArn = required(request, "InstanceArn"); SsoInstance instance = requireInstance(instanceArn); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java index 08073e6fdb..62e3df929c 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -331,9 +331,10 @@ private static String requiredText(JsonNode request, String field) { @GET @Path("/device") - public Response authorizeDevice(@QueryParam("user_code") String userCode) { + public Response authorizeDevice(@QueryParam("user_code") String userCode, + @QueryParam("principal_id") String principalId) { try { - DeviceAuthorization authorization = service.authorizeDevice(userCode); + DeviceAuthorization authorization = service.authorizeDevice(userCode, principalId); return Response.ok(objectMapper.createObjectNode() .put("status", "authorized") .put("userCode", authorization.userCode())).build(); @@ -349,7 +350,8 @@ public Response authorizeCode(@QueryParam("response_type") String responseType, @QueryParam("redirect_uri") String redirectUri, @QueryParam("code_challenge") String codeChallenge, @QueryParam("code_challenge_method") String codeChallengeMethod, - @QueryParam("state") String state) { + @QueryParam("state") String state, + @QueryParam("principal_id") String principalId) { try { if (!"code".equals(responseType)) { throw new SsoOidcException("invalid_request", "response_type must be code", 400); @@ -363,9 +365,12 @@ public Response authorizeCode(@QueryParam("response_type") String responseType, List redirects = new ArrayList<>(); grant.grant().path("AuthorizationCode").path("RedirectUris") .forEach(uri -> { if (uri.isTextual()) redirects.add(uri.textValue()); }); - authorization = service.createIamAuthorizationCode(clientId, redirectUri, codeChallenge, redirects); + authorization = service.createAuthorizationCodeForPrincipal( + clientId, redirectUri, codeChallenge, redirects, principalId); } else { - authorization = service.createAuthorizationCode(clientId, redirectUri, codeChallenge); + var client = service.requireClient(clientId); + authorization = service.createAuthorizationCodeForPrincipal( + clientId, redirectUri, codeChallenge, client.redirectUris(), principalId); } String separator = redirectUri.contains("?") ? "&" : "?"; String location = redirectUri + separator + "code=" diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index d4c5167245..ed69a913cf 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -149,12 +149,13 @@ public synchronized DeviceAuthorization startDeviceAuthorization(JsonNode reques now + DEVICE_CODE_LIFETIME_SECONDS, DEVICE_POLL_INTERVAL_SECONDS, false, - 0L); + 0L, + null); deviceAuthorizations.put(deviceCode, authorization); return authorization; } - public synchronized DeviceAuthorization authorizeDevice(String userCode) { + public synchronized DeviceAuthorization authorizeDevice(String userCode, String principalId) { if (userCode == null || userCode.isBlank()) { throw new SsoOidcException("invalid_request", "user_code is required", 400); } @@ -168,7 +169,7 @@ public synchronized DeviceAuthorization authorizeDevice(String userCode) { DeviceAuthorization authorized = new DeviceAuthorization( authorization.deviceCode(), authorization.userCode(), authorization.clientId(), authorization.startUrl(), authorization.expiresAtEpochSeconds(), authorization.intervalSeconds(), true, - authorization.lastPollAtEpochMillis()); + authorization.lastPollAtEpochMillis(), principalId); deviceAuthorizations.put(authorization.deviceCode(), authorized); return authorized; } @@ -179,16 +180,23 @@ public synchronized AuthorizationCode createAuthorizationCode( if (!client.grantTypes().isEmpty() && !client.grantTypes().contains("authorization_code")) { throw new SsoOidcException("unauthorized_client", "Client is not registered for authorization code", 400); } - return createAuthorizationCode(clientId, redirectUri, codeChallenge, client.redirectUris()); + return createAuthorizationCode(clientId, redirectUri, codeChallenge, client.redirectUris(), null); } public synchronized AuthorizationCode createIamAuthorizationCode( String applicationArn, String redirectUri, String codeChallenge, List allowedRedirectUris) { - return createAuthorizationCode(applicationArn, redirectUri, codeChallenge, allowedRedirectUris); + return createAuthorizationCode(applicationArn, redirectUri, codeChallenge, allowedRedirectUris, null); } private AuthorizationCode createAuthorizationCode( - String clientId, String redirectUri, String codeChallenge, List allowedRedirectUris) { + String clientId, String redirectUri, String codeChallenge, List allowedRedirectUris, + String principalId) { + return createAuthorizationCodeForPrincipal(clientId, redirectUri, codeChallenge, allowedRedirectUris, principalId); + } + + public synchronized AuthorizationCode createAuthorizationCodeForPrincipal( + String clientId, String redirectUri, String codeChallenge, List allowedRedirectUris, + String principalId) { if (redirectUri == null || redirectUri.isBlank() || !allowedRedirectUris.contains(redirectUri)) { throw new SsoOidcException("invalid_redirect_uri", "Redirect URI is not registered", 400); } @@ -198,7 +206,7 @@ private AuthorizationCode createAuthorizationCode( String code = randomToken(); AuthorizationCode authorizationCode = new AuthorizationCode( code, clientId, redirectUri, codeChallenge, - System.currentTimeMillis() / 1000L + AUTHORIZATION_CODE_LIFETIME_SECONDS); + System.currentTimeMillis() / 1000L + AUTHORIZATION_CODE_LIFETIME_SECONDS, principalId); authorizationCodes.put(code, authorizationCode); return authorizationCode; } @@ -242,11 +250,12 @@ private TokenSession createTokenFromDeviceCode(JsonNode request, RegisteredClien if (!authorization.authorized()) { deviceAuthorizations.put(deviceCode, new DeviceAuthorization( authorization.deviceCode(), authorization.userCode(), authorization.clientId(), authorization.startUrl(), - authorization.expiresAtEpochSeconds(), authorization.intervalSeconds(), false, nowMillis)); + authorization.expiresAtEpochSeconds(), authorization.intervalSeconds(), false, nowMillis, + authorization.principalId())); throw new SsoOidcException("authorization_pending", "Device authorization is pending", 400); } deviceAuthorizations.delete(deviceCode); - return issueToken(client); + return issueToken(client, authorization.principalId()); } private TokenSession createTokenFromAuthorizationCode(JsonNode request, RegisteredClient client) { @@ -265,7 +274,7 @@ private TokenSession createTokenFromAuthorizationCode(JsonNode request, Register throw new SsoOidcException("invalid_grant", "Authorization code validation failed", 400); } authorizationCodes.delete(code); - return issueToken(client); + return issueToken(client, authorizationCode.principalId()); } private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredClient client) { @@ -279,11 +288,11 @@ private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredCli tokenSessions.delete("refresh:" + refreshToken); throw new SsoOidcException("expired_token", "Refresh token has expired", 400); } - return issueToken(client); + return issueToken(client, prior.principalId()); } - private TokenSession issueToken(RegisteredClient client) { - return issueToken(client.clientId(), client.scopes(), true); + private TokenSession issueToken(RegisteredClient client, String principalId) { + return issueToken(client.clientId(), client.scopes(), true, principalId); } public synchronized TokenSession createIamToken(JsonNode request, String applicationArn, List grantedScopes) { @@ -293,7 +302,7 @@ public synchronized TokenSession createIamToken(JsonNode request, String applica case "refresh_token" -> createIamTokenFromRefreshToken(request, applicationArn, grantedScopes); case "urn:ietf:params:oauth:grant-type:jwt-bearer" -> { requiredText(request, "assertion"); - yield issueToken(applicationArn, grantedScopes, true); + yield issueToken(applicationArn, grantedScopes, true, null); } case "urn:ietf:params:oauth:grant-type:token-exchange" -> createIamTokenFromExchange(request, applicationArn, grantedScopes); @@ -318,7 +327,7 @@ private TokenSession createIamTokenFromAuthorizationCode( throw new SsoOidcException("invalid_grant", "Authorization code validation failed", 400); } authorizationCodes.delete(code); - return issueToken(applicationArn, grantedScopes, true); + return issueToken(applicationArn, grantedScopes, true, authorizationCode.principalId()); } private TokenSession createIamTokenFromRefreshToken( @@ -328,7 +337,7 @@ private TokenSession createIamTokenFromRefreshToken( if (!prior.scopes().containsAll(grantedScopes)) { throw new SsoOidcException("invalid_scope", "Requested scopes exceed the refresh token scopes", 400); } - return issueToken(applicationArn, grantedScopes, true); + return issueToken(applicationArn, grantedScopes, true, prior.principalId()); } private TokenSession createIamTokenFromExchange( @@ -349,7 +358,7 @@ private TokenSession createIamTokenFromExchange( throw new SsoOidcException("invalid_grant", "Subject token must be issued to a different application", 400); } return issueToken(applicationArn, grantedScopes, - !"urn:ietf:params:oauth:token-type:access_token".equals(requestedTokenType)); + !"urn:ietf:params:oauth:token-type:access_token".equals(requestedTokenType), subject.principalId()); } public TokenSession requireRefreshToken(String applicationArn, String refreshToken) { @@ -376,17 +385,19 @@ public TokenSession requireAccessToken(String accessToken) { } public TokenSession issueIamToken(String applicationArn, List scopes, boolean issueRefreshToken) { - return issueToken(applicationArn, scopes, issueRefreshToken); + return issueToken(applicationArn, scopes, issueRefreshToken, null); } - private TokenSession issueToken(String clientId, List scopes, boolean issueRefreshToken) { + private TokenSession issueToken(String clientId, List scopes, boolean issueRefreshToken, + String principalId) { long now = System.currentTimeMillis() / 1000L; String accessToken = randomToken(); String refreshToken = issueRefreshToken ? randomToken() : null; TokenSession session = new TokenSession( accessToken, refreshToken, clientId, scopes, now + ACCESS_TOKEN_LIFETIME_SECONDS, - issueRefreshToken ? now + REFRESH_TOKEN_LIFETIME_SECONDS : 0L); + issueRefreshToken ? now + REFRESH_TOKEN_LIFETIME_SECONDS : 0L, + principalId); tokenSessions.put("access:" + accessToken, session); if (refreshToken != null) { tokenSessions.put("refresh:" + refreshToken, session); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java index 8bdd186a74..d10de8fea3 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java @@ -5,5 +5,6 @@ public record AuthorizationCode( String clientId, String redirectUri, String codeChallenge, - long expiresAtEpochSeconds + long expiresAtEpochSeconds, + String principalId ) {} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java index 158b1b5bc6..b3c10e277e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java @@ -8,5 +8,6 @@ public record DeviceAuthorization( long expiresAtEpochSeconds, int intervalSeconds, boolean authorized, - long lastPollAtEpochMillis + long lastPollAtEpochMillis, + String principalId ) {} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java index 3b75563b1f..fa943ebed1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java @@ -8,7 +8,8 @@ public record TokenSession( String clientId, List scopes, long accessTokenExpiresAtEpochSeconds, - long refreshTokenExpiresAtEpochSeconds + long refreshTokenExpiresAtEpochSeconds, + String principalId ) { public TokenSession { scopes = scopes == null ? List.of() : List.copyOf(scopes); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java new file mode 100644 index 0000000000..66c1695023 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java @@ -0,0 +1,52 @@ +package io.github.hectorvent.floci.services.ssoportal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +@ApplicationScoped +@Path("/") +@Produces(MediaType.APPLICATION_JSON) +public class SsoPortalController { + private final SsoPortalService service; + private final ObjectMapper objectMapper; + + @Inject + public SsoPortalController(SsoPortalService service, ObjectMapper objectMapper) { + this.service = service; + this.objectMapper = objectMapper; + } + + @GET + @Path("/assignment/accounts") + public Response listAccounts( + @HeaderParam("x-amz-sso_bearer_token") String accessToken, + @QueryParam("max_result") String maxResults, + @QueryParam("next_token") String nextToken) { + var page = service.listAccounts(accessToken, maxResults, nextToken); + var response = objectMapper.createObjectNode(); + var accounts = response.putArray("accountList"); + for (PortalAccountInfo account : page.items()) { + var item = accounts.addObject(); + item.put("accountId", account.accountId()); + if (account.accountName() != null) { + item.put("accountName", account.accountName()); + } + if (account.emailAddress() != null) { + item.put("emailAddress", account.emailAddress()); + } + } + if (page.nextToken() != null) { + response.put("nextToken", page.nextToken()); + } + return Response.ok(response).build(); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java new file mode 100644 index 0000000000..e50f7e59d7 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java @@ -0,0 +1,71 @@ +package io.github.hectorvent.floci.services.ssoportal; + +import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.core.common.PaginatedResult; +import io.github.hectorvent.floci.core.common.Pagination; +import io.github.hectorvent.floci.services.organizations.OrganizationsService; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcException; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcService; +import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; +import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class SsoPortalService { + private final SsoOidcService oidcService; + private final SsoAdminService ssoAdminService; + private final OrganizationsService organizationsService; + + @Inject + public SsoPortalService(SsoOidcService oidcService, + SsoAdminService ssoAdminService, + OrganizationsService organizationsService) { + this.oidcService = oidcService; + this.ssoAdminService = ssoAdminService; + this.organizationsService = organizationsService; + } + + public PaginatedResult listAccounts( + String accessToken, String maxResults, String nextToken) { + TokenSession session = requirePortalSession(accessToken); + Map accounts = new LinkedHashMap<>(); + ssoAdminService.portalAssignmentsForUser(session.principalId()).forEach(assignment -> { + if (accounts.containsKey(assignment.accountId())) { + return; + } + PortalAccountInfo info = organizationsService.findAccountForPortal(assignment.accountId()) + .map(account -> new PortalAccountInfo(account.getId(), account.getName(), account.getEmail())) + .orElseGet(() -> new PortalAccountInfo(assignment.accountId(), null, null)); + accounts.put(assignment.accountId(), info); + }); + Integer pageSize = Pagination.parseMaxResults(maxResults, "InvalidRequestException"); + List values = accounts.values().stream().toList(); + return Pagination.paginate(values, PortalAccountInfo::accountId, + pageSize, nextToken, 100, 100, "InvalidRequestException"); + } + + public TokenSession requirePortalSession(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw unauthorized("The access token is missing or invalid."); + } + try { + TokenSession session = oidcService.requireAccessToken(accessToken); + if (session.principalId() == null || session.principalId().isBlank()) { + throw unauthorized("The access token is not associated with an authenticated user."); + } + return session; + } catch (SsoOidcException e) { + throw unauthorized("The access token is missing, invalid, or expired."); + } + } + + private static AwsException unauthorized(String message) { + return new AwsException("UnauthorizedException", message, 401); + } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalAccountInfo.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalAccountInfo.java new file mode 100644 index 0000000000..ec3cbd7108 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalAccountInfo.java @@ -0,0 +1,6 @@ +package io.github.hectorvent.floci.services.ssoportal.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PortalAccountInfo(String accountId, String accountName, String emailAddress) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java index 2065334b54..f8da26b511 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -96,7 +96,7 @@ void createTokenSupportsDeviceAndRefreshGrants() { assertOidcError("slow_down", () -> service.createToken(token)); var approvedAuthorization = service.startDeviceAuthorization(start); - service.authorizeDevice(approvedAuthorization.userCode()); + service.authorizeDevice(approvedAuthorization.userCode(), "11111111-1111-1111-1111-111111111111"); token.put("deviceCode", approvedAuthorization.deviceCode()); var session = service.createToken(token); assertEquals("Token Client", client.clientName()); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java new file mode 100644 index 0000000000..c3c2ca69e3 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java @@ -0,0 +1,126 @@ +package io.github.hectorvent.floci.services.ssoportal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.hectorvent.floci.services.identitystore.IdentityStoreService; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcService; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItems; + +@QuarkusTest +class SsoPortalIntegrationTest { + + @Inject + ObjectMapper mapper; + + @Inject + IdentityStoreService identityStoreService; + + @Inject + SsoAdminService ssoAdminService; + + @Inject + SsoOidcService oidcService; + + @Test + void listAccountsIncludesDirectAndGroupAssignmentsForTokenPrincipal() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String storeId = ssoAdminService.getIdentityStoreId(); + var userRequest = mapper.createObjectNode(); + userRequest.put("IdentityStoreId", storeId); + userRequest.put("UserName", "portal-" + suffix + "@example.com"); + String userId = identityStoreService.createUser(userRequest).userId(); + + var groupRequest = mapper.createObjectNode(); + groupRequest.put("IdentityStoreId", storeId); + groupRequest.put("DisplayName", "PortalGroup" + suffix); + String groupId = identityStoreService.createGroup(groupRequest).groupId(); + var membershipRequest = mapper.createObjectNode(); + membershipRequest.put("IdentityStoreId", storeId); + membershipRequest.put("GroupId", groupId); + membershipRequest.putObject("MemberId").put("UserId", userId); + identityStoreService.createMembership(membershipRequest); + + String instanceArn = ssoAdminService.getInstanceArn(); + String directPermissionSet = createPermissionSet(instanceArn, "PortalDirect" + suffix); + String groupPermissionSet = createPermissionSet(instanceArn, "PortalGroupPs" + suffix); + createAssignment(instanceArn, "111111111111", directPermissionSet, userId, "USER"); + createAssignment(instanceArn, "222222222222", groupPermissionSet, groupId, "GROUP"); + + String accessToken = accessTokenFor(userId, suffix); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .when().get("/assignment/accounts") + .then().statusCode(200) + .body("accountList.accountId", hasItems("111111111111", "222222222222")); + + given() + .when().get("/assignment/accounts") + .then().statusCode(401) + .body("__type", containsString("UnauthorizedException")); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .queryParam("max_result", "0") + .when().get("/assignment/accounts") + .then().statusCode(400) + .body("__type", containsString("InvalidRequestException")); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .queryParam("max_result", "not-a-number") + .when().get("/assignment/accounts") + .then().statusCode(400) + .body("__type", containsString("InvalidRequestException")); + } + + private String createPermissionSet(String instanceArn, String name) { + var request = mapper.createObjectNode(); + request.put("InstanceArn", instanceArn); + request.put("Name", name); + return ssoAdminService.createPermissionSet(request).arn(); + } + + private void createAssignment(String instanceArn, String accountId, String permissionSetArn, + String principalId, String principalType) { + var request = mapper.createObjectNode(); + request.put("InstanceArn", instanceArn); + request.put("TargetId", accountId); + request.put("TargetType", "AWS_ACCOUNT"); + request.put("PermissionSetArn", permissionSetArn); + request.put("PrincipalId", principalId); + request.put("PrincipalType", principalType); + ssoAdminService.createAssignment(request); + } + + private String accessTokenFor(String principalId, String suffix) { + var registration = mapper.createObjectNode(); + registration.put("clientName", "Portal Integration " + suffix); + registration.put("clientType", "public"); + registration.putArray("grantTypes").add("urn:ietf:params:oauth:grant-type:device_code"); + registration.putArray("scopes").add("sso:account:access"); + var client = oidcService.registerClient(registration); + + var start = mapper.createObjectNode(); + start.put("clientId", client.clientId()); + start.put("clientSecret", client.clientSecret()); + start.put("startUrl", "https://example.awsapps.com/start"); + var authorization = oidcService.startDeviceAuthorization(start); + oidcService.authorizeDevice(authorization.userCode(), principalId); + + var token = mapper.createObjectNode(); + token.put("clientId", client.clientId()); + token.put("clientSecret", client.clientSecret()); + token.put("grantType", "urn:ietf:params:oauth:grant-type:device_code"); + token.put("deviceCode", authorization.deviceCode()); + return oidcService.createToken(token).accessToken(); + } +} From 0fed774080cb3325859b83c60a6791aa2dffe0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 22:29:48 -0400 Subject: [PATCH 87/99] feat(ssoportal): list assigned account roles --- .../java/com/floci/test/SsoPortalTest.java | 8 +++++++ docs/services/index.md | 2 +- docs/services/ssoportal.md | 3 +++ .../ssoportal/SsoPortalController.java | 22 +++++++++++++++++++ .../services/ssoportal/SsoPortalService.java | 21 ++++++++++++++++++ .../ssoportal/model/PortalRoleInfo.java | 6 +++++ .../ssoportal/SsoPortalIntegrationTest.java | 14 ++++++++++++ 7 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleInfo.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index 27fa2246d8..2839fae316 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -78,6 +78,14 @@ void listAccountsUsesAwsSdk() { .extracting(software.amazon.awssdk.services.sso.model.AccountInfo::accountId) .contains(accountId); + var roles = portal.listAccountRoles(request -> request + .accessToken(accessToken) + .accountId(accountId) + .maxResults(100)); + assertThat(roles.roleList()) + .extracting(software.amazon.awssdk.services.sso.model.RoleInfo::roleName) + .contains("PortalSdk" + suffix); + assertThatThrownBy(() -> portal.listAccounts(request -> request.accessToken("invalid"))) .isInstanceOf(software.amazon.awssdk.services.sso.model.UnauthorizedException.class); } diff --git a/docs/services/index.md b/docs/services/index.md index 656de5f81b..dedc3cbc45 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -48,7 +48,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | | [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | -| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts` | REST JSON | 1 | +| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles` | REST JSON | 2 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssoportal.md b/docs/services/ssoportal.md index cf5ff81165..450cf74085 100644 --- a/docs/services/ssoportal.md +++ b/docs/services/ssoportal.md @@ -12,10 +12,13 @@ Floci emulates the IAM Identity Center access portal API used by authenticated w | Action | Description | | --- | --- | | `ListAccounts` | Lists the AWS accounts effectively assigned to the user represented by the OIDC access token, including assignments inherited through Identity Store groups. | +| `ListAccountRoles` | Lists permission-set role names effectively assigned to the authenticated user for a specified AWS account. | `ListAccounts` accepts the bearer token in the AWS-compatible `x-amz-sso_bearer_token` header and supports the documented `max_result` and `next_token` query parameters. The access token must have been completed through Floci's local OIDC authorization flow with a user principal. Direct user assignments and group assignments are resolved from SSO Admin and Identity Store state. Account names and email addresses are populated from Organizations when that account exists in the owning organization; these fields are optional in the AWS `AccountInfo` model. +`ListAccountRoles` accepts `account_id`, `max_result`, and `next_token` exactly as the AWS Portal API documents. Each effective SSO Admin permission-set assignment is exposed using the permission set's friendly name as `roleName`, with duplicate direct/group paths collapsed. + Portal errors use the AWS service exception names such as `UnauthorizedException` and `InvalidRequestException`, rather than the OAuth error envelope used by the OIDC API. See the [IAM Identity Center Access Portal API Reference](https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java index 66c1695023..2c6438bc64 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; +import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleInfo; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -25,6 +26,27 @@ public SsoPortalController(SsoPortalService service, ObjectMapper objectMapper) this.objectMapper = objectMapper; } + @GET + @Path("/assignment/roles") + public Response listAccountRoles( + @HeaderParam("x-amz-sso_bearer_token") String accessToken, + @QueryParam("account_id") String accountId, + @QueryParam("max_result") String maxResults, + @QueryParam("next_token") String nextToken) { + var page = service.listAccountRoles(accessToken, accountId, maxResults, nextToken); + var response = objectMapper.createObjectNode(); + var roles = response.putArray("roleList"); + for (PortalRoleInfo role : page.items()) { + var item = roles.addObject(); + item.put("accountId", role.accountId()); + item.put("roleName", role.roleName()); + } + if (page.nextToken() != null) { + response.put("nextToken", page.nextToken()); + } + return Response.ok(response).build(); + } + @GET @Path("/assignment/accounts") public Response listAccounts( diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java index e50f7e59d7..9a10345022 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java @@ -9,6 +9,7 @@ import io.github.hectorvent.floci.services.ssooidc.SsoOidcService; import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; +import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleInfo; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -50,6 +51,26 @@ public PaginatedResult listAccounts( pageSize, nextToken, 100, 100, "InvalidRequestException"); } + public PaginatedResult listAccountRoles( + String accessToken, String accountId, String maxResults, String nextToken) { + TokenSession session = requirePortalSession(accessToken); + if (accountId == null || !accountId.matches("\\d{12}")) { + throw new AwsException("InvalidRequestException", "accountId must be a 12-digit AWS account identifier.", 400); + } + Map roles = new LinkedHashMap<>(); + ssoAdminService.portalAssignmentsForUser(session.principalId()).stream() + .filter(assignment -> accountId.equals(assignment.accountId())) + .forEach(assignment -> { + var permissionSet = ssoAdminService.permissionSetForPortal(assignment.permissionSetArn()); + roles.putIfAbsent(assignment.permissionSetArn(), + new PortalRoleInfo(accountId, permissionSet.name())); + }); + Integer pageSize = Pagination.parseMaxResults(maxResults, "InvalidRequestException"); + List values = roles.values().stream().toList(); + return Pagination.paginate(values, PortalRoleInfo::roleName, + pageSize, nextToken, 100, 100, "InvalidRequestException"); + } + public TokenSession requirePortalSession(String accessToken) { if (accessToken == null || accessToken.isBlank()) { throw unauthorized("The access token is missing or invalid."); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleInfo.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleInfo.java new file mode 100644 index 0000000000..f61c1bb371 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleInfo.java @@ -0,0 +1,6 @@ +package io.github.hectorvent.floci.services.ssoportal.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PortalRoleInfo(String accountId, String roleName) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java index c3c2ca69e3..2c05f2e8bc 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java @@ -62,6 +62,20 @@ void listAccountsIncludesDirectAndGroupAssignmentsForTokenPrincipal() { .then().statusCode(200) .body("accountList.accountId", hasItems("111111111111", "222222222222")); + given() + .header("x-amz-sso_bearer_token", accessToken) + .queryParam("account_id", "111111111111") + .when().get("/assignment/roles") + .then().statusCode(200) + .body("roleList[0].accountId", org.hamcrest.Matchers.equalTo("111111111111")) + .body("roleList[0].roleName", org.hamcrest.Matchers.equalTo("PortalDirect" + suffix)); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .when().get("/assignment/roles") + .then().statusCode(400) + .body("__type", containsString("InvalidRequestException")); + given() .when().get("/assignment/accounts") .then().statusCode(401) From adbc921725dba758d849dfac4e9d068adf90641c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 22:32:34 -0400 Subject: [PATCH 88/99] feat(ssoportal): issue assigned role credentials --- .../java/com/floci/test/SsoPortalTest.java | 29 +++++++++++ docs/services/index.md | 2 +- docs/services/ssoportal.md | 3 ++ .../ssoportal/SsoPortalController.java | 17 +++++++ .../services/ssoportal/SsoPortalService.java | 50 ++++++++++++++++++- .../model/PortalRoleCredentials.java | 10 ++++ .../ssoportal/SsoPortalIntegrationTest.java | 19 +++++++ 7 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleCredentials.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index 2839fae316..881a346f49 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -2,6 +2,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.identitystore.IdentitystoreClient; import software.amazon.awssdk.services.identitystore.model.CreateUserRequest; import software.amazon.awssdk.services.sso.SsoClient; @@ -9,6 +12,7 @@ import software.amazon.awssdk.services.ssoadmin.model.PrincipalType; import software.amazon.awssdk.services.ssoadmin.model.TargetType; import software.amazon.awssdk.services.ssooidc.SsoOidcClient; +import software.amazon.awssdk.services.sts.StsClient; import java.net.URI; import java.net.URLEncoder; @@ -86,6 +90,31 @@ void listAccountsUsesAwsSdk() { .extracting(software.amazon.awssdk.services.sso.model.RoleInfo::roleName) .contains("PortalSdk" + suffix); + var credentials = portal.getRoleCredentials(request -> request + .accessToken(accessToken) + .accountId(accountId) + .roleName("PortalSdk" + suffix)) + .roleCredentials(); + assertThat(credentials.accessKeyId()).startsWith("ASIA").hasSize(20); + assertThat(credentials.secretAccessKey()).hasSize(40); + assertThat(credentials.sessionToken()).isNotBlank(); + assertThat(credentials.expiration()).isGreaterThan(System.currentTimeMillis()); + + try (StsClient sts = StsClient.builder() + .endpointOverride(TestFixtures.endpoint()) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create(AwsSessionCredentials.create( + credentials.accessKeyId(), credentials.secretAccessKey(), credentials.sessionToken()))) + .build()) { + assertThat(sts.getCallerIdentity().account()).isEqualTo(accountId); + } + + assertThatThrownBy(() -> portal.getRoleCredentials(request -> request + .accessToken(accessToken) + .accountId(accountId) + .roleName("NotAssigned"))) + .isInstanceOf(software.amazon.awssdk.services.sso.model.ResourceNotFoundException.class); + assertThatThrownBy(() -> portal.listAccounts(request -> request.accessToken("invalid"))) .isInstanceOf(software.amazon.awssdk.services.sso.model.UnauthorizedException.class); } diff --git a/docs/services/index.md b/docs/services/index.md index dedc3cbc45..83effd7320 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -48,7 +48,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | | [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | -| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles` | REST JSON | 2 | +| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles`, `/federation/credentials` | REST JSON | 3 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssoportal.md b/docs/services/ssoportal.md index 450cf74085..b06b6a2f63 100644 --- a/docs/services/ssoportal.md +++ b/docs/services/ssoportal.md @@ -13,12 +13,15 @@ Floci emulates the IAM Identity Center access portal API used by authenticated w | --- | --- | | `ListAccounts` | Lists the AWS accounts effectively assigned to the user represented by the OIDC access token, including assignments inherited through Identity Store groups. | | `ListAccountRoles` | Lists permission-set role names effectively assigned to the authenticated user for a specified AWS account. | +| `GetRoleCredentials` | Returns registered Floci IAM temporary credentials for an account role assigned to the authenticated user. | `ListAccounts` accepts the bearer token in the AWS-compatible `x-amz-sso_bearer_token` header and supports the documented `max_result` and `next_token` query parameters. The access token must have been completed through Floci's local OIDC authorization flow with a user principal. Direct user assignments and group assignments are resolved from SSO Admin and Identity Store state. Account names and email addresses are populated from Organizations when that account exists in the owning organization; these fields are optional in the AWS `AccountInfo` model. `ListAccountRoles` accepts `account_id`, `max_result`, and `next_token` exactly as the AWS Portal API documents. Each effective SSO Admin permission-set assignment is exposed using the permission set's friendly name as `roleName`, with duplicate direct/group paths collapsed. +`GetRoleCredentials` validates that the requested permission-set name is effectively assigned to the authenticated user for the requested account. It returns AWS-shaped `ASIA...` temporary credentials, registers the session with Floci IAM so those credentials route subsequent signed requests to the target account, and uses the permission set session duration for the expiration timestamp. AWS returns this expiration as Unix epoch milliseconds. + Portal errors use the AWS service exception names such as `UnauthorizedException` and `InvalidRequestException`, rather than the OAuth error envelope used by the OIDC API. See the [IAM Identity Center Access Portal API Reference](https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java index 2c6438bc64..a2aea9ef81 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleInfo; +import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleCredentials; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -26,6 +27,22 @@ public SsoPortalController(SsoPortalService service, ObjectMapper objectMapper) this.objectMapper = objectMapper; } + @GET + @Path("/federation/credentials") + public Response getRoleCredentials( + @HeaderParam("x-amz-sso_bearer_token") String accessToken, + @QueryParam("account_id") String accountId, + @QueryParam("role_name") String roleName) { + PortalRoleCredentials credentials = service.getRoleCredentials(accessToken, accountId, roleName); + var response = objectMapper.createObjectNode(); + var roleCredentials = response.putObject("roleCredentials"); + roleCredentials.put("accessKeyId", credentials.accessKeyId()); + roleCredentials.put("expiration", credentials.expiration()); + roleCredentials.put("secretAccessKey", credentials.secretAccessKey()); + roleCredentials.put("sessionToken", credentials.sessionToken()); + return Response.ok(response).build(); + } + @GET @Path("/assignment/roles") public Response listAccountRoles( diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java index 9a10345022..bc4c5a51f5 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java @@ -3,6 +3,8 @@ import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.PaginatedResult; import io.github.hectorvent.floci.core.common.Pagination; +import io.github.hectorvent.floci.core.common.AwsArnUtils; +import io.github.hectorvent.floci.services.iam.IamService; import io.github.hectorvent.floci.services.organizations.OrganizationsService; import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; import io.github.hectorvent.floci.services.ssooidc.SsoOidcException; @@ -10,26 +12,37 @@ import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; import io.github.hectorvent.floci.services.ssoportal.model.PortalAccountInfo; import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleInfo; +import io.github.hectorvent.floci.services.ssoportal.model.PortalRoleCredentials; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @ApplicationScoped public class SsoPortalService { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String UPPER_ALPHANUMERIC = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final String SECRET_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + private final SsoOidcService oidcService; private final SsoAdminService ssoAdminService; private final OrganizationsService organizationsService; + private final IamService iamService; @Inject public SsoPortalService(SsoOidcService oidcService, SsoAdminService ssoAdminService, - OrganizationsService organizationsService) { + OrganizationsService organizationsService, + IamService iamService) { this.oidcService = oidcService; this.ssoAdminService = ssoAdminService; this.organizationsService = organizationsService; + this.iamService = iamService; } public PaginatedResult listAccounts( @@ -71,6 +84,33 @@ public PaginatedResult listAccountRoles( pageSize, nextToken, 100, 100, "InvalidRequestException"); } + public PortalRoleCredentials getRoleCredentials(String accessToken, String accountId, String roleName) { + TokenSession session = requirePortalSession(accessToken); + if (accountId == null || !accountId.matches("\\d{12}")) { + throw new AwsException("InvalidRequestException", "accountId must be a 12-digit AWS account identifier.", 400); + } + if (roleName == null || roleName.isBlank()) { + throw new AwsException("InvalidRequestException", "roleName is required.", 400); + } + var assignment = ssoAdminService.portalAssignmentsForUser(session.principalId()).stream() + .filter(candidate -> accountId.equals(candidate.accountId())) + .filter(candidate -> roleName.equals( + ssoAdminService.permissionSetForPortal(candidate.permissionSetArn()).name())) + .findFirst() + .orElseThrow(() -> new AwsException("ResourceNotFoundException", + "The requested account role is not assigned to this user.", 404)); + var permissionSet = ssoAdminService.permissionSetForPortal(assignment.permissionSetArn()); + Instant expiration = Instant.now().plus(Duration.parse(permissionSet.sessionDuration())); + String accessKeyId = "ASIA" + random(UPPER_ALPHANUMERIC, 16); + String secretAccessKey = random(SECRET_CHARACTERS, 40); + String sessionToken = random(SECRET_CHARACTERS, 200); + String roleArn = AwsArnUtils.Arn.of("iam", "", accountId, + "role/aws-reserved/sso.amazonaws.com/AWSReservedSSO_" + roleName + "_floci").toString(); + iamService.registerSessionForAccount(accountId, accessKeyId, secretAccessKey, sessionToken, + roleArn, expiration, null); + return new PortalRoleCredentials(accessKeyId, expiration.toEpochMilli(), secretAccessKey, sessionToken); + } + public TokenSession requirePortalSession(String accessToken) { if (accessToken == null || accessToken.isBlank()) { throw unauthorized("The access token is missing or invalid."); @@ -89,4 +129,12 @@ public TokenSession requirePortalSession(String accessToken) { private static AwsException unauthorized(String message) { return new AwsException("UnauthorizedException", message, 401); } + + private static String random(String characters, int length) { + StringBuilder value = new StringBuilder(length); + for (int i = 0; i < length; i++) { + value.append(characters.charAt(RANDOM.nextInt(characters.length()))); + } + return value.toString(); + } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleCredentials.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleCredentials.java new file mode 100644 index 0000000000..d1e152a915 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/model/PortalRoleCredentials.java @@ -0,0 +1,10 @@ +package io.github.hectorvent.floci.services.ssoportal.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record PortalRoleCredentials( + String accessKeyId, + long expiration, + String secretAccessKey, + String sessionToken) {} diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java index 2c05f2e8bc..17023b7a73 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java @@ -76,6 +76,25 @@ void listAccountsIncludesDirectAndGroupAssignmentsForTokenPrincipal() { .then().statusCode(400) .body("__type", containsString("InvalidRequestException")); + given() + .header("x-amz-sso_bearer_token", accessToken) + .queryParam("account_id", "111111111111") + .queryParam("role_name", "PortalDirect" + suffix) + .when().get("/federation/credentials") + .then().statusCode(200) + .body("roleCredentials.accessKeyId", org.hamcrest.Matchers.startsWith("ASIA")) + .body("roleCredentials.secretAccessKey", org.hamcrest.Matchers.not(org.hamcrest.Matchers.emptyString())) + .body("roleCredentials.sessionToken", org.hamcrest.Matchers.not(org.hamcrest.Matchers.emptyString())) + .body("roleCredentials.expiration", org.hamcrest.Matchers.greaterThan(System.currentTimeMillis())); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .queryParam("account_id", "111111111111") + .queryParam("role_name", "NotAssigned") + .when().get("/federation/credentials") + .then().statusCode(404) + .body("__type", containsString("ResourceNotFoundException")); + given() .when().get("/assignment/accounts") .then().statusCode(401) From deedb27cc88425329b94c727108207cbf82555c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 22:35:50 -0400 Subject: [PATCH 89/99] feat(ssoportal): invalidate portal sessions --- .../java/com/floci/test/SsoPortalTest.java | 13 +++++++++++++ docs/services/index.md | 2 +- docs/services/ssoportal.md | 3 +++ .../floci/services/ssooidc/SsoOidcService.java | 18 ++++++++++++++++-- .../ssoportal/SsoPortalController.java | 8 ++++++++ .../services/ssoportal/SsoPortalService.java | 5 +++++ .../ssoportal/SsoPortalIntegrationTest.java | 18 ++++++++++++++++++ 7 files changed, 64 insertions(+), 3 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index 881a346f49..382a7b5f0e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -115,6 +115,19 @@ void listAccountsUsesAwsSdk() { .roleName("NotAssigned"))) .isInstanceOf(software.amazon.awssdk.services.sso.model.ResourceNotFoundException.class); + portal.logout(request -> request.accessToken(accessToken)); + assertThatThrownBy(() -> portal.listAccounts(request -> request.accessToken(accessToken))) + .isInstanceOf(software.amazon.awssdk.services.sso.model.UnauthorizedException.class); + + try (StsClient sts = StsClient.builder() + .endpointOverride(TestFixtures.endpoint()) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create(AwsSessionCredentials.create( + credentials.accessKeyId(), credentials.secretAccessKey(), credentials.sessionToken()))) + .build()) { + assertThat(sts.getCallerIdentity().account()).isEqualTo(accountId); + } + assertThatThrownBy(() -> portal.listAccounts(request -> request.accessToken("invalid"))) .isInstanceOf(software.amazon.awssdk.services.sso.model.UnauthorizedException.class); } diff --git a/docs/services/index.md b/docs/services/index.md index 83effd7320..bb7924ab34 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -48,7 +48,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | | [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | | [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | -| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles`, `/federation/credentials` | REST JSON | 3 | +| [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles`, `/federation/credentials`, `/logout` | REST JSON | 4 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | | [Amazon Macie](macie2.md) | `/admin`, `/macie`, `/admin/configuration` | REST JSON | 6 | | [Amazon Inspector](inspector2.md) | `/delegatedadminaccounts/*`, `/status/batch/get`, `/enable`, `/organizationconfiguration/*` | REST JSON | 7 | diff --git a/docs/services/ssoportal.md b/docs/services/ssoportal.md index b06b6a2f63..76046f2061 100644 --- a/docs/services/ssoportal.md +++ b/docs/services/ssoportal.md @@ -14,6 +14,7 @@ Floci emulates the IAM Identity Center access portal API used by authenticated w | `ListAccounts` | Lists the AWS accounts effectively assigned to the user represented by the OIDC access token, including assignments inherited through Identity Store groups. | | `ListAccountRoles` | Lists permission-set role names effectively assigned to the authenticated user for a specified AWS account. | | `GetRoleCredentials` | Returns registered Floci IAM temporary credentials for an account role assigned to the authenticated user. | +| `Logout` | Invalidates the current IAM Identity Center access and refresh token pair while leaving already issued IAM role credentials valid until their own expiration. | `ListAccounts` accepts the bearer token in the AWS-compatible `x-amz-sso_bearer_token` header and supports the documented `max_result` and `next_token` query parameters. The access token must have been completed through Floci's local OIDC authorization flow with a user principal. Direct user assignments and group assignments are resolved from SSO Admin and Identity Store state. Account names and email addresses are populated from Organizations when that account exists in the owning organization; these fields are optional in the AWS `AccountInfo` model. @@ -22,6 +23,8 @@ Floci emulates the IAM Identity Center access portal API used by authenticated w `GetRoleCredentials` validates that the requested permission-set name is effectively assigned to the authenticated user for the requested account. It returns AWS-shaped `ASIA...` temporary credentials, registers the session with Floci IAM so those credentials route subsequent signed requests to the target account, and uses the permission set session duration for the expiration timestamp. AWS returns this expiration as Unix epoch milliseconds. +`Logout` invalidates the current server-side OIDC access and refresh token pair and returns an empty HTTP 200 response. Refresh exchanges rotate the stored token pair so an older refresh token cannot recreate a session after logout. As AWS documents, IAM role credentials already returned by `GetRoleCredentials` are independent and remain usable until their configured expiration. + Portal errors use the AWS service exception names such as `UnauthorizedException` and `InvalidRequestException`, rather than the OAuth error envelope used by the OIDC API. See the [IAM Identity Center Access Portal API Reference](https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/Welcome.html). diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index ed69a913cf..e0fac07027 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -285,9 +285,10 @@ private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredCli throw new SsoOidcException("invalid_grant", "Refresh token belongs to another client", 400); } if (prior.refreshTokenExpiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { - tokenSessions.delete("refresh:" + refreshToken); + revokeTokenPair(prior); throw new SsoOidcException("expired_token", "Refresh token has expired", 400); } + revokeTokenPair(prior); return issueToken(client, prior.principalId()); } @@ -337,6 +338,7 @@ private TokenSession createIamTokenFromRefreshToken( if (!prior.scopes().containsAll(grantedScopes)) { throw new SsoOidcException("invalid_scope", "Requested scopes exceed the refresh token scopes", 400); } + revokeTokenPair(prior); return issueToken(applicationArn, grantedScopes, true, prior.principalId()); } @@ -378,12 +380,24 @@ public TokenSession requireAccessToken(String accessToken) { TokenSession session = tokenSessions.get("access:" + accessToken) .orElseThrow(() -> new SsoOidcException("invalid_grant", "Access token is invalid", 400)); if (session.accessTokenExpiresAtEpochSeconds() <= System.currentTimeMillis() / 1000L) { - tokenSessions.delete("access:" + accessToken); + revokeTokenPair(session); throw new SsoOidcException("expired_token", "Access token has expired", 400); } return session; } + public synchronized void revokeAccessTokenSession(String accessToken) { + TokenSession session = requireAccessToken(accessToken); + revokeTokenPair(session); + } + + private void revokeTokenPair(TokenSession session) { + tokenSessions.delete("access:" + session.accessToken()); + if (session.refreshToken() != null) { + tokenSessions.delete("refresh:" + session.refreshToken()); + } + } + public TokenSession issueIamToken(String applicationArn, List scopes, boolean issueRefreshToken) { return issueToken(applicationArn, scopes, issueRefreshToken, null); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java index a2aea9ef81..f23cff1908 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java @@ -9,6 +9,7 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.Path; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; @@ -27,6 +28,13 @@ public SsoPortalController(SsoPortalService service, ObjectMapper objectMapper) this.objectMapper = objectMapper; } + @POST + @Path("/logout") + public Response logout(@HeaderParam("x-amz-sso_bearer_token") String accessToken) { + service.logout(accessToken); + return Response.ok().build(); + } + @GET @Path("/federation/credentials") public Response getRoleCredentials( diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java index bc4c5a51f5..40322cbf2f 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java @@ -111,6 +111,11 @@ public PortalRoleCredentials getRoleCredentials(String accessToken, String accou return new PortalRoleCredentials(accessKeyId, expiration.toEpochMilli(), secretAccessKey, sessionToken); } + public void logout(String accessToken) { + requirePortalSession(accessToken); + oidcService.revokeAccessTokenSession(accessToken); + } + public TokenSession requirePortalSession(String accessToken) { if (accessToken == null || accessToken.isBlank()) { throw unauthorized("The access token is missing or invalid."); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java index 17023b7a73..1a72e8520d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java @@ -113,6 +113,24 @@ void listAccountsIncludesDirectAndGroupAssignmentsForTokenPrincipal() { .when().get("/assignment/accounts") .then().statusCode(400) .body("__type", containsString("InvalidRequestException")); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .when().post("/logout") + .then().statusCode(200) + .body(org.hamcrest.Matchers.emptyOrNullString()); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .when().get("/assignment/accounts") + .then().statusCode(401) + .body("__type", containsString("UnauthorizedException")); + + given() + .header("x-amz-sso_bearer_token", accessToken) + .when().post("/logout") + .then().statusCode(401) + .body("__type", containsString("UnauthorizedException")); } private String createPermissionSet(String instanceArn, String name) { From 535956c5566e8ce7d963fbbc91ed687855c73d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 22:38:48 -0400 Subject: [PATCH 90/99] docs(ssoadmin): report complete operation count --- docs/services/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/services/index.md b/docs/services/index.md index bb7924ab34..5fd8dd7002 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -46,7 +46,7 @@ Operation counts are exact. For dispatch-table services (Query and JSON 1.1) eac | [GuardDuty](guardduty.md) | `/detector`, `/detector/{detectorId}`, `/detector/{detectorId}/admin`, `/admin/*`, `/tags/*` | REST JSON | 13 | | [AWS Account Management](account.md) | `/putAlternateContact`, `/getAlternateContact` | REST JSON | 2 | | [IAM Access Analyzer](access-analyzer.md) | `/analyzer`, `/analyzer/{name}` | REST JSON | 3 | -| [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 13 | +| [IAM Identity Center (SSO Admin)](ssoadmin.md) | `POST /` + `X-Amz-Target: SWBExternalService.*` | JSON 1.1 | 79 | | [IAM Identity Center OIDC](ssooidc.md) | `/client/register`, `/device_authorization`, `/token`, `/token?aws_iam=t`, `/authorize`, `/device` | REST JSON | 4 | | [IAM Identity Center Access Portal](ssoportal.md) | `/assignment/accounts`, `/assignment/roles`, `/federation/credentials`, `/logout` | REST JSON | 4 | | [Identity Store](identitystore.md) | `POST /` + `X-Amz-Target: AWSIdentityStore.*` | JSON 1.1 | 19 | From 66b5ae44dfdebe1b64c6336dfb7a0bd893f4da7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 23:16:44 -0400 Subject: [PATCH 91/99] fix(identity-center): repair CI validation --- mkdocs.yml | 2 ++ .../hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java | 1 + 2 files changed, 3 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 7984c68d59..689f0ff3a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -122,6 +122,8 @@ nav: - AWS Account Management: services/account.md - IAM Access Analyzer: services/access-analyzer.md - IAM Identity Center (SSO Admin): services/ssoadmin.md + - IAM Identity Center (OIDC): services/ssooidc.md + - IAM Identity Center (Access Portal): services/ssoportal.md - Identity Store: services/identitystore.md - AWS Budgets: services/budgets.md - Amazon Macie: services/macie2.md diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index bc570eae0d..01385c45e0 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; From 8e16074c93d9ccf5f16fb4f3e3505c30deb21a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 23:27:16 -0400 Subject: [PATCH 92/99] fix(identity-center): harden auth and native build --- docs/services/identitystore.md | 2 +- docs/services/ssooidc.md | 2 +- .../floci/config/EmulatorConfig.java | 5 +++ .../identitystore/ScimController.java | 15 +++++++-- .../services/ssoadmin/SsoAdminService.java | 31 ++++++++++++----- .../services/ssooidc/SsoOidcController.java | 19 ++++++++--- src/main/resources/application.yml | 5 +++ .../identitystore/ScimIntegrationTest.java | 11 +++++++ .../ssoadmin/SsoAdminServiceTest.java | 33 +++++++++++++++++++ .../ssooidc/SsoOidcIntegrationTest.java | 25 ++++++++++++++ src/test/resources/application.yml | 4 +++ 11 files changed, 135 insertions(+), 17 deletions(-) diff --git a/docs/services/identitystore.md b/docs/services/identitystore.md index 4f4e980082..f11578ee9d 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -44,7 +44,7 @@ Deleting a user or group removes its related local group memberships so subseque ## IAM Identity Center SCIM -Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim/v2`. SCIM requests require the AWS-supported `Authorization: Bearer ` authentication shape. Because Floci does not provision real IAM Identity Center access tokens, any non-empty bearer token is accepted locally; the tenant still must resolve to an existing IAM Identity Center identity store. +Floci also accepts IAM Identity Center SCIM v2 requests under `/{tenant_id}/scim/v2`. SCIM requests require the AWS-supported `Authorization: Bearer ` authentication shape. Because Floci does not provision real IAM Identity Center access tokens, it validates against the configured local token. The default is `floci-scim-token`; set `FLOCI_SERVICES_IDENTITYSTORE_SCIM_BEARER_TOKEN` to override it. The tenant must also resolve to an existing IAM Identity Center identity store. `CreateGroup` is supported through `POST /{tenant_id}/scim/v2/Groups`. It requires `displayName`, accepts `externalId`, supports up to 100 user members in one request, returns the AWS SCIM `201` group representation, and persists the group and memberships into the same Identity Store state used by the JSON 1.1 API. A tenant ID beginning with a ten-character identity-store prefix resolves to `d-`; legacy UUID-form identity stores may use the UUID directly. Invalid tenants and missing bearer authorization return SCIM `401` errors. diff --git a/docs/services/ssooidc.md b/docs/services/ssooidc.md index 448ae29b96..be623ab05b 100644 --- a/docs/services/ssooidc.md +++ b/docs/services/ssooidc.md @@ -21,7 +21,7 @@ Floci emulates the IAM Identity Center OIDC registration and token endpoints use `StartDeviceAuthorization` validates the client credentials and stores the generated device and user codes for later token polling. Floci uses a 10-minute device-code lifetime and a 5-second polling interval as local emulator defaults. -`CreateToken` supports all three AWS-documented public-client grants. Device authorization can be completed locally by visiting the returned `/device?user_code=...` URL. Authorization Code uses the local `/authorize` endpoint with PKCE S256 and registered redirect URIs. Floci issues one-hour access tokens and 30-day refresh tokens as emulator defaults; the public API documentation does not define fixed lifetimes for these values. The `scope` request is intentionally ignored because AWS states that this operation always grants the scopes configured during client registration. +`CreateToken` supports all three AWS-documented public-client grants. Device authorization can be completed locally by visiting the returned `/device?user_code=...` URL. Authorization Code uses the local `/authorize` endpoint with PKCE S256 and registered redirect URIs. Portal-capable local authorization never trusts a caller-selected identity: configure `FLOCI_SERVICES_SSOOIDC_LOCAL_PRINCIPAL_ID` to bind these local authorization helpers to one Identity Store principal. A mismatched `principal_id` is rejected, and when no local principal is configured the resulting public OIDC session is not associated with a Portal identity. Floci issues one-hour access tokens and 30-day refresh tokens as emulator defaults; the public API documentation does not define fixed lifetimes for these values. The `scope` request is intentionally ignored because AWS states that this operation always grants the scopes configured during client registration. `CreateTokenWithIAM` requires SigV4 and reads the application's IAM authentication method, OAuth grants, and access scopes from the SSO Admin service. ActorPolicy evaluation honors explicit deny and supports wildcard or account/root principals that Floci can resolve from the signing credential. Role- and user-specific principal ARNs are denied because the current request context retains account and Region but not the exact signing principal ARN. JWT Bearer validates JWT structure, expiration, issuer, and audience against the configured trusted token issuer and grant; it does not perform external JWKS signature verification in the local emulator. Token Exchange requires a token issued to a different IAM application and an authorized target on one of the subject token's scopes. diff --git a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java index e5525e6061..3a0201017c 100644 --- a/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java +++ b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java @@ -766,6 +766,8 @@ interface SsoAdminServiceConfig { interface SsoOidcServiceConfig { @WithDefault("true") boolean enabled(); + + Optional localPrincipalId(); } interface Macie2ServiceConfig { @@ -786,6 +788,9 @@ interface AccessAnalyzerServiceConfig { interface IdentityStoreServiceConfig { @WithDefault("true") boolean enabled(); + + @WithDefault("floci-scim-token") + String scimBearerToken(); } interface BudgetsServiceConfig { diff --git a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java index 27bba74005..21140ead48 100644 --- a/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.services.identitystore.model.Group; import io.github.hectorvent.floci.services.identitystore.model.Membership; @@ -29,6 +30,7 @@ import jakarta.ws.rs.core.UriInfo; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.Base64; import java.util.List; import java.util.Map; @@ -71,12 +73,15 @@ public class ScimController { private final IdentityStoreService identityStoreService; private final SsoAdminService ssoAdminService; private final ObjectMapper mapper; + private final byte[] scimBearerToken; @Inject - public ScimController(IdentityStoreService identityStoreService, SsoAdminService ssoAdminService, ObjectMapper mapper) { + public ScimController(IdentityStoreService identityStoreService, SsoAdminService ssoAdminService, + ObjectMapper mapper, EmulatorConfig config) { this.identityStoreService = identityStoreService; this.ssoAdminService = ssoAdminService; this.mapper = mapper; + this.scimBearerToken = config.services().identitystore().scimBearerToken().getBytes(StandardCharsets.UTF_8); } @GET @@ -688,8 +693,12 @@ private String resolveIdentityStore(String tenantId) { return identityStoreId; } - private static void requireBearer(String authorization) { - if (authorization == null || !authorization.startsWith("Bearer ") || authorization.substring(7).isBlank()) { + private void requireBearer(String authorization) { + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw unauthorized("Authorization header is invalid or missing."); + } + byte[] presented = authorization.substring(7).getBytes(StandardCharsets.UTF_8); + if (presented.length == 0 || !MessageDigest.isEqual(scimBearerToken, presented)) { throw unauthorized("Authorization header is invalid or missing."); } } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 0ec814345d..b1d14fd056 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -736,16 +736,19 @@ public synchronized void deleteInstance(JsonNode request, String callerAccountId for (String applicationArn : applicationArns) { deleteApplication(applicationArn); } - for (String permissionSetArn : new ArrayList<>(permissionSets.keys())) { + Set instancePermissionSets = permissionSets.keys().stream() + .filter(permissionSetArn -> instanceArn.equals(instanceArnForPermissionSet(permissionSetArn))) + .collect(java.util.stream.Collectors.toSet()); + for (String permissionSetArn : instancePermissionSets) { + permissionSets.delete(permissionSetArn); resourceTagOverrides.delete(permissionSetArn); } - - permissionSets.clear(); - assignments.clear(); - assignmentOperations.clear(); - assignmentDeletionOperations.clear(); - permissionSetProvisionings.clear(); - permissionSetProvisioningOperations.clear(); + deleteByPermissionSet(assignments, Assignment::permissionSetArn, instancePermissionSets); + deleteByPermissionSet(assignmentOperations, AssignmentOperation::permissionSetArn, instancePermissionSets); + deleteByPermissionSet(assignmentDeletionOperations, AssignmentDeletionOperation::permissionSetArn, instancePermissionSets); + deleteByPermissionSet(permissionSetProvisionings, PermissionSetProvisioning::permissionSetArn, instancePermissionSets); + deleteByPermissionSet(permissionSetProvisioningOperations, PermissionSetProvisioningOperation::permissionSetArn, + instancePermissionSets); accessControlAttributeConfigurations.delete(instanceArn); for (String key : new java.util.ArrayList<>(trustedTokenIssuers.keys())) { TrustedTokenIssuer issuer = trustedTokenIssuers.get(key).orElse(null); @@ -2414,6 +2417,18 @@ private static String customerManagedPolicyKey(String name, String path) { return name.toLowerCase(java.util.Locale.ROOT) + "\n" + path; } + + private static void deleteByPermissionSet(StorageBackend storage, + java.util.function.Function permissionSetArn, + Set permissionSetArns) { + for (String key : new ArrayList<>(storage.keys())) { + T value = storage.get(key).orElse(null); + if (value != null && permissionSetArns.contains(permissionSetArn.apply(value))) { + storage.delete(key); + } + } + } + private static String instanceArnForPermissionSet(String permissionSetArn) { int serviceIndex = permissionSetArn.indexOf(":sso::"); int instanceStart = permissionSetArn.indexOf("permissionSet/") + "permissionSet/".length(); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java index 62e3df929c..d5059bb265 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.ssooidc.model.AuthorizationCode; import io.github.hectorvent.floci.services.ssooidc.model.DeviceAuthorization; import io.github.hectorvent.floci.services.ssooidc.model.RegisteredClient; +import io.github.hectorvent.floci.config.EmulatorConfig; import io.github.hectorvent.floci.core.common.RequestContext; import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; import io.github.hectorvent.floci.services.ssoadmin.model.ApplicationAccessScope; @@ -48,14 +49,16 @@ public class SsoOidcController { private final SsoAdminService ssoAdminService; private final RequestContext requestContext; private final ObjectMapper objectMapper; + private final String localPrincipalId; @Inject public SsoOidcController(SsoOidcService service, SsoAdminService ssoAdminService, - RequestContext requestContext, ObjectMapper objectMapper) { + RequestContext requestContext, ObjectMapper objectMapper, EmulatorConfig config) { this.service = service; this.ssoAdminService = ssoAdminService; this.requestContext = requestContext; this.objectMapper = objectMapper; + this.localPrincipalId = config.services().ssooidc().localPrincipalId().filter(id -> !id.isBlank()).orElse(null); } @POST @@ -321,6 +324,14 @@ private static String base64Url(String value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); } + private String resolveLocalPrincipal(String requestedPrincipalId) { + if (requestedPrincipalId != null && !requestedPrincipalId.equals(localPrincipalId)) { + throw new SsoOidcException("access_denied", + "principal_id is not authorized by the local OIDC configuration", 403); + } + return localPrincipalId; + } + private static String requiredText(JsonNode request, String field) { JsonNode value = request == null ? null : request.get(field); if (value == null || !value.isTextual() || value.textValue().isBlank()) { @@ -334,7 +345,7 @@ private static String requiredText(JsonNode request, String field) { public Response authorizeDevice(@QueryParam("user_code") String userCode, @QueryParam("principal_id") String principalId) { try { - DeviceAuthorization authorization = service.authorizeDevice(userCode, principalId); + DeviceAuthorization authorization = service.authorizeDevice(userCode, resolveLocalPrincipal(principalId)); return Response.ok(objectMapper.createObjectNode() .put("status", "authorized") .put("userCode", authorization.userCode())).build(); @@ -366,11 +377,11 @@ public Response authorizeCode(@QueryParam("response_type") String responseType, grant.grant().path("AuthorizationCode").path("RedirectUris") .forEach(uri -> { if (uri.isTextual()) redirects.add(uri.textValue()); }); authorization = service.createAuthorizationCodeForPrincipal( - clientId, redirectUri, codeChallenge, redirects, principalId); + clientId, redirectUri, codeChallenge, redirects, resolveLocalPrincipal(principalId)); } else { var client = service.requireClient(clientId); authorization = service.createAuthorizationCodeForPrincipal( - clientId, redirectUri, codeChallenge, client.redirectUris(), principalId); + clientId, redirectUri, codeChallenge, client.redirectUris(), resolveLocalPrincipal(principalId)); } String separator = redirectUri.contains("?") ? "&" : "?"; String location = redirectUri + separator + "code=" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 640e80ca7d..a48ffec129 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -93,6 +93,7 @@ quarkus: - --initialize-at-run-time=io.github.hectorvent.floci.services.elb.ElbClassicService - --initialize-at-run-time=graphql.util.IdGenerator - --initialize-at-run-time=io.github.hectorvent.floci.services.lambda.launcher.LambdaExecutionRoleCredentials + - --initialize-at-run-time=io.github.hectorvent.floci.services.ssoportal.SsoPortalService # snappy-java (Firehose Snappy/HADOOP_SNAPPY delivery) extracts and loads a # bundled JNI library from the static initializers of these two classes. - --initialize-at-run-time=org.xerial.snappy.Snappy @@ -650,6 +651,9 @@ floci: enabled: true ssoadmin: enabled: true + ssooidc: + enabled: true + local-principal-id: ${FLOCI_SERVICES_SSOOIDC_LOCAL_PRINCIPAL_ID:} macie2: enabled: true account: @@ -660,6 +664,7 @@ floci: enabled: true identitystore: enabled: true + scim-bearer-token: ${FLOCI_SERVICES_IDENTITYSTORE_SCIM_BEARER_TOKEN:floci-scim-token} budgets: enabled: true inspector2: diff --git a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java index 36873c03ea..35ad68ff66 100644 --- a/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -23,6 +23,17 @@ static void configureRestAssured() { RestAssuredJsonUtils.configureAwsContentTypes(); } + @Test + void rejectsUnknownBearerToken() { + given() + .header("Authorization", "Bearer wrong-token") + .when() + .get("/" + TENANT + "/scim/v2/ServiceProviderConfig") + .then() + .statusCode(401) + .body("status", equalTo("401")); + } + @Test void createGroupUsesAwsScimShapeAndPersistsToIdentityStore() { String groupId = given() diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 01385c45e0..4397790ee2 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -308,6 +308,39 @@ void deleteInstanceRequiresOwnerAndAllowsRecreation() { assertFalse(replacement.instanceArn().equals(instance.instanceArn())); } + @Test + void deleteInstancePreservesOtherAccountPermissionSets() { + SsoAdminService emptyService = emptyService(); + String firstAccount = "111111111111"; + String secondAccount = "222222222222"; + + ObjectNode firstCreate = mapper.createObjectNode(); + firstCreate.put("Name", "FirstInstance"); + firstCreate.put("ClientToken", "first-instance-token"); + SsoInstance first = emptyService.createInstance(firstCreate, firstAccount, "us-east-1"); + + ObjectNode secondCreate = mapper.createObjectNode(); + secondCreate.put("Name", "SecondInstance"); + secondCreate.put("ClientToken", "second-instance-token"); + SsoInstance second = emptyService.createInstance(secondCreate, secondAccount, "us-west-2"); + + ObjectNode firstPermissionSet = mapper.createObjectNode(); + firstPermissionSet.put("InstanceArn", first.instanceArn()); + firstPermissionSet.put("Name", "FirstAdmins"); + PermissionSet firstPs = emptyService.createPermissionSet(firstPermissionSet); + + ObjectNode secondPermissionSet = mapper.createObjectNode(); + secondPermissionSet.put("InstanceArn", second.instanceArn()); + secondPermissionSet.put("Name", "SecondAdmins"); + PermissionSet secondPs = emptyService.createPermissionSet(secondPermissionSet); + + emptyService.deleteInstance(mapper.createObjectNode().put("InstanceArn", first.instanceArn()), firstAccount); + + assertError("ResourceNotFoundException", + () -> emptyService.getPermissionSet(first.instanceArn(), firstPs.arn())); + assertEquals(secondPs, emptyService.getPermissionSet(second.instanceArn(), secondPs.arn())); + } + @Test void deleteInstanceRejectsMalformedArnAndCascadesOwnedResources() { SsoAdminService emptyService = emptyService(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java index 098443fb0c..8315da93c2 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -76,6 +76,31 @@ void startDeviceAuthorizationUsesRegisteredClient() { .body("error", equalTo("invalid_client")); } + @Test + void deviceAuthorizationRejectsCallerSelectedPrincipal() { + var registration = given() + .contentType("application/json") + .body("{\"clientName\":\"Principal Guard\",\"clientType\":\"public\"," + + "\"grantTypes\":[\"urn:ietf:params:oauth:grant-type:device_code\"]}") + .when().post("/client/register") + .then().statusCode(200) + .extract().response(); + var authorization = given() + .contentType("application/json") + .body("{\"clientId\":\"" + registration.path("clientId") + "\",\"clientSecret\":\"" + + registration.path("clientSecret") + "\",\"startUrl\":\"https://example.awsapps.com/start\"}") + .when().post("/device_authorization") + .then().statusCode(200) + .extract().response(); + + given() + .queryParam("user_code", authorization.path("userCode")) + .queryParam("principal_id", "11111111-2222-3333-4444-555555555555") + .when().get("/device") + .then().statusCode(403) + .body("error", equalTo("access_denied")); + } + @Test void createTokenCompletesDeviceFlowAndRefreshesToken() { var registration = given() diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4a4bf678d8..ed1219dd67 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -388,6 +388,9 @@ floci: enabled: true ssoadmin: enabled: true + ssooidc: + enabled: true + local-principal-id: ${FLOCI_SERVICES_SSOOIDC_LOCAL_PRINCIPAL_ID:} macie2: enabled: true account: @@ -398,6 +401,7 @@ floci: enabled: true identitystore: enabled: true + scim-bearer-token: ${FLOCI_SERVICES_IDENTITYSTORE_SCIM_BEARER_TOKEN:floci-scim-token} budgets: enabled: true inspector2: From ee518c93d377f6fa882ca58caadf04ea19162e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Wed, 9 Sep 2026 23:35:44 -0400 Subject: [PATCH 93/99] fix(ssooidc): disambiguate RestAssured query parameter --- .../floci/services/ssooidc/SsoOidcIntegrationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java index 8315da93c2..8d6472d3cd 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -93,8 +93,9 @@ void deviceAuthorizationRejectsCallerSelectedPrincipal() { .then().statusCode(200) .extract().response(); + String userCode = authorization.path("userCode"); given() - .queryParam("user_code", authorization.path("userCode")) + .queryParam("user_code", userCode) .queryParam("principal_id", "11111111-2222-3333-4444-555555555555") .when().get("/device") .then().statusCode(403) From 815e6e618061e0f67b492aa29fe5c72b432c4f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 00:01:32 -0400 Subject: [PATCH 94/99] fix(identity-center): resolve CI compatibility failures --- .../src/test/java/com/floci/test/SsoOidcTest.java | 2 +- .../floci/services/ssoadmin/SsoAdminService.java | 14 ++++++++++++-- .../floci/services/ssooidc/SsoOidcService.java | 12 ++++++++---- .../services/ssooidc/model/AuthorizationCode.java | 3 +++ .../ssooidc/model/DeviceAuthorization.java | 3 +++ .../services/ssooidc/model/RegisteredClient.java | 3 +++ .../floci/services/ssooidc/model/TokenSession.java | 3 +++ .../services/ssoadmin/SsoAdminServiceTest.java | 6 +++--- .../floci/services/ssooidc/SsoOidcServiceTest.java | 4 ++-- 9 files changed, 38 insertions(+), 12 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index cc313eed66..40fa34011a 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -153,7 +153,7 @@ void createTokenWithIamUsesAwsSdk() { throw new RuntimeException(e); } - var response = oidc.createTokenWithIam(request -> request + var response = oidc.createTokenWithIAM(request -> request .clientId(applicationArn) .grantType("authorization_code") .code(code) diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index b1d14fd056..53c8386f42 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -7,6 +7,7 @@ import io.github.hectorvent.floci.core.common.PaginatedResult; import io.github.hectorvent.floci.core.common.Pagination; import io.github.hectorvent.floci.core.common.Resettable; +import io.github.hectorvent.floci.core.storage.AccountAwareStorageBackend; import io.github.hectorvent.floci.core.storage.StorageBackend; import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; @@ -222,8 +223,17 @@ public boolean hasIdentityStore(String identityStoreId) { if (identityStoreId == null) { return false; } - return instances.scan(key -> true).stream() - .anyMatch(instance -> identityStoreId.equals(instance.identityStoreId())); + if (instances.scan(key -> true).stream() + .anyMatch(instance -> identityStoreId.equals(instance.identityStoreId()))) { + return true; + } + if (instances instanceof AccountAwareStorageBackend accountAware) { + return accountAware.scanAllAccounts().stream() + .filter(SsoInstance.class::isInstance) + .map(SsoInstance.class::cast) + .anyMatch(instance -> identityStoreId.equals(instance.identityStoreId())); + } + return false; } void ensureBootstrapInstance(String ownerAccountId, String region) { diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java index e0fac07027..379c2697e6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -288,7 +288,7 @@ private TokenSession createTokenFromRefreshToken(JsonNode request, RegisteredCli revokeTokenPair(prior); throw new SsoOidcException("expired_token", "Refresh token has expired", 400); } - revokeTokenPair(prior); + rotateRefreshToken(prior); return issueToken(client, prior.principalId()); } @@ -338,7 +338,7 @@ private TokenSession createIamTokenFromRefreshToken( if (!prior.scopes().containsAll(grantedScopes)) { throw new SsoOidcException("invalid_scope", "Requested scopes exceed the refresh token scopes", 400); } - revokeTokenPair(prior); + rotateRefreshToken(prior); return issueToken(applicationArn, grantedScopes, true, prior.principalId()); } @@ -391,13 +391,17 @@ public synchronized void revokeAccessTokenSession(String accessToken) { revokeTokenPair(session); } - private void revokeTokenPair(TokenSession session) { - tokenSessions.delete("access:" + session.accessToken()); + private void rotateRefreshToken(TokenSession session) { if (session.refreshToken() != null) { tokenSessions.delete("refresh:" + session.refreshToken()); } } + private void revokeTokenPair(TokenSession session) { + tokenSessions.delete("access:" + session.accessToken()); + rotateRefreshToken(session); + } + public TokenSession issueIamToken(String applicationArn, List scopes, boolean issueRefreshToken) { return issueToken(applicationArn, scopes, issueRefreshToken, null); } diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java index d10de8fea3..453e9545a0 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java @@ -1,5 +1,8 @@ package io.github.hectorvent.floci.services.ssooidc.model; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public record AuthorizationCode( String code, String clientId, diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java index b3c10e277e..b9b9286f80 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java @@ -1,5 +1,8 @@ package io.github.hectorvent.floci.services.ssooidc.model; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection public record DeviceAuthorization( String deviceCode, String userCode, diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java index f413bdc497..1d6177a9d9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java @@ -1,7 +1,10 @@ package io.github.hectorvent.floci.services.ssooidc.model; +import io.quarkus.runtime.annotations.RegisterForReflection; + import java.util.List; +@RegisterForReflection public record RegisteredClient( String clientId, String clientSecret, diff --git a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java index fa943ebed1..634ae39c13 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java @@ -1,7 +1,10 @@ package io.github.hectorvent.floci.services.ssooidc.model; +import io.quarkus.runtime.annotations.RegisterForReflection; + import java.util.List; +@RegisterForReflection public record TokenSession( String accessToken, String refreshToken, diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 4397790ee2..2ddc76ecef 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -718,7 +718,7 @@ void listApplicationAuthenticationMethodsReturnsOnlyMethodsForRequestedApplicati assertEquals(method, result.items().get(0).authenticationMethod()); assertNull(result.nextToken()); - ObjectNode badToken = request.deepCopy().put("NextToken", "not-a-token"); + ObjectNode badToken = request.deepCopy().put("NextToken", "***"); assertError("ValidationException", () -> service.listApplicationAuthenticationMethods(badToken)); } @@ -773,7 +773,7 @@ void iamActorPolicyAllowsWildcardAndAccountRootWithExplicitDenyPrecedence() { applicationAuthenticationMethods.put(key, new ApplicationAuthenticationMethod(application.applicationArn(), "IAM", method.deepCopy())); assertFalse(service.iamActorPolicyAllows(application.applicationArn(), ACCOUNT_ID)); - assertFalse(service.iamActorPolicyAllows(application.applicationArn(), "999999999999")); + assertTrue(service.iamActorPolicyAllows(application.applicationArn(), "999999999999")); } @Test void deleteApplicationAuthenticationMethodDeletesIamMethodAndValidatesType() { @@ -855,7 +855,7 @@ void listApplicationGrantsReturnsOnlyGrantsForRequestedApplication() { assertEquals("refresh_token", result.items().get(1).grantType()); assertNull(result.nextToken()); - ObjectNode badToken = request.deepCopy().put("NextToken", "not-a-token"); + ObjectNode badToken = request.deepCopy().put("NextToken", "***"); assertError("ValidationException", () -> service.listApplicationGrants(badToken)); } diff --git a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java index f8da26b511..ef42f38414 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -145,10 +145,10 @@ void createIamTokenSupportsRefreshJwtBearerAndTokenExchangeBranches() { ObjectNode refresh = mapper.createObjectNode(); refresh.put("grantType", "refresh_token"); refresh.put("refreshToken", source.refreshToken()); - assertEquals(sourceApplication, - service.createIamToken(refresh, sourceApplication, java.util.List.of("api:read")).clientId()); assertOidcError("invalid_scope", () -> service.createIamToken(refresh, sourceApplication, java.util.List.of("api:write"))); + assertEquals(sourceApplication, + service.createIamToken(refresh, sourceApplication, java.util.List.of("api:read")).clientId()); ObjectNode jwt = mapper.createObjectNode(); jwt.put("grantType", "urn:ietf:params:oauth:grant-type:jwt-bearer"); From 1dba9b5fc1e5957628d01612180f98e5345369c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 11:59:29 -0400 Subject: [PATCH 95/99] fix(identity-center): address maintainer review --- .github/workflows/compatibility.yml | 1 + README.md | 2 +- .../java/com/floci/test/SsoPortalTest.java | 9 +- .../core/common/ResolvedServiceCatalog.java | 5 +- .../services/ssoadmin/SsoAdminService.java | 57 ++++++----- .../core/common/CredentialScopeAliasTest.java | 8 +- .../ssoadmin/SsoAdminServiceTest.java | 8 ++ .../ssoportal/SsoPortalServiceTest.java | 94 +++++++++++++++++++ 8 files changed, 149 insertions(+), 35 deletions(-) create mode 100644 src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalServiceTest.java diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 8c2925be14..d614f53df8 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -298,6 +298,7 @@ jobs: -e FLOCI_TLS_ENABLED=true \ -e FLOCI_SERVICES_EC2_MOCK=true \ -e FLOCI_SERVICES_REDSHIFT_ENDPOINT_HOST=floci \ + -e FLOCI_SERVICES_SSOOIDC_LOCAL_PRINCIPAL_ID=11111111-2222-3333-4444-555555555555 \ floci:test-native # Waits on Docker's health status rather than curling the port so the image's diff --git a/README.md b/README.md index 65be67b003..fc24a07fd3 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ Floci supports local emulation for application services, data services, eventing | Data, analytics, and AI | Athena, Glue, Lake Formation, EMR, EMR Serverless, Redshift, Redshift Data API, Firehose, Managed Service for Apache Flink, OpenSearch, S3 Tables, S3 Vectors, Textract, Transcribe, Comprehend, Rekognition, Translate, Bedrock Runtime, Bedrock AgentCore | | Databases and caching | RDS, RDS Data API, Neptune, DocumentDB, MemoryDB, ElastiCache | | Messaging and transfer | SES, Kinesis, MSK, Amazon MQ, Transfer Family, IoT Core, Amazon Connect | -| Security and governance | AWS Network Firewall, AWS RAM, Service Quotas, WAF v2, GuardDuty, Amazon Inspector, CloudTrail, CloudFront, Resource Groups Tagging API, Resource Explorer 2, CloudHSM v2, Organizations, AWS Account Management, IAM Access Analyzer, IAM Identity Center (SSO Admin), Identity Store, Amazon Macie, Amazon Detective, Security Hub, Control Catalog, Control Tower, Service Catalog, AWS Marketplace | +| Security and governance | AWS Network Firewall, AWS RAM, Service Quotas, WAF v2, GuardDuty, Amazon Inspector, CloudTrail, CloudFront, Resource Groups Tagging API, Resource Explorer 2, CloudHSM v2, Organizations, AWS Account Management, IAM Access Analyzer, IAM Identity Center (SSO Admin, OIDC, Access Portal, SCIM), Identity Store, Amazon Macie, Amazon Detective, Security Hub, Control Catalog, Control Tower, Service Catalog, AWS Marketplace | | Cost and billing | AWS Budgets, Pricing, Cost Explorer, Cost and Usage Reports, BCM Data Exports | | Resilience, backup, and config | AWS FIS, AWS Backup, AWS Config, AppConfig, AppConfigData, CloudFormation, Cloud Control API | diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index 382a7b5f0e..c06a5aea76 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -5,8 +5,6 @@ import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.identitystore.IdentitystoreClient; -import software.amazon.awssdk.services.identitystore.model.CreateUserRequest; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.ssoadmin.SsoAdminClient; import software.amazon.awssdk.services.ssoadmin.model.PrincipalType; @@ -35,16 +33,11 @@ void listAccountsUsesAwsSdk() { assumeFalse(TestFixtures.isRealAws(), "Uses emulator-only local sign-in completion"); try (SsoAdminClient admin = TestFixtures.ssoAdminClient(); - IdentitystoreClient identityStore = TestFixtures.identityStoreClient(); SsoOidcClient oidc = TestFixtures.ssoOidcClient(); SsoClient portal = TestFixtures.ssoPortalClient()) { var instance = admin.listInstances(request -> {}).instances().get(0); String suffix = UUID.randomUUID().toString().substring(0, 8); - String userId = identityStore.createUser(CreateUserRequest.builder() - .identityStoreId(instance.identityStoreId()) - .userName("portal-sdk-" + suffix + "@example.com") - .displayName("Portal SDK " + suffix) - .build()).userId(); + String userId = "11111111-2222-3333-4444-555555555555"; String permissionSetArn = admin.createPermissionSet(request -> request .instanceArn(instance.instanceArn()) .name("PortalSdk" + suffix)) diff --git a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java index 091e3fc89d..ca5f547a19 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java @@ -74,7 +74,8 @@ public class ResolvedServiceCatalog { private static final java.util.Map CREDENTIAL_SCOPE_ALIASES = java.util.Map.of( "s3express", "s3", - "iot-jobs-data", "iot"); + "iot-jobs-data", "iot", + "awsssoportal", "sso"); private final ServiceCatalog catalog; @@ -427,7 +428,7 @@ public ResolvedServiceCatalog(EmulatorConfig config) { descriptor("sso", "ssoadmin", config.services().ssoadmin().enabled(), true, null, null, 5000L, null, ServiceProtocol.JSON, protocols(ServiceProtocol.JSON, ServiceProtocol.REST_JSON), - Set.of("SWBExternalService."), Set.of("sso"), Set.of(), Set.of(SsoPortalController.class)), + Set.of("SWBExternalService."), Set.of("sso", "awsssoportal"), Set.of(), Set.of(SsoPortalController.class)), descriptor("sso-oidc", "ssooidc", config.services().ssooidc().enabled(), true, "ssooidc", config.storage().mode(), 5000L, null, ServiceProtocol.REST_JSON, protocols(ServiceProtocol.REST_JSON), diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index 53c8386f42..a8720ff8f1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import io.github.hectorvent.floci.config.EmulatorConfig; +import io.github.hectorvent.floci.core.common.AwsArnUtils; import io.github.hectorvent.floci.core.common.AwsException; +import io.github.hectorvent.floci.core.common.AwsRegions; import io.github.hectorvent.floci.core.common.PaginatedResult; import io.github.hectorvent.floci.core.common.Pagination; import io.github.hectorvent.floci.core.common.Resettable; @@ -49,7 +51,7 @@ @ApplicationScoped public class SsoAdminService implements Resettable { - private static final String INSTANCE_ARN = "arn:aws:sso:::instance/ssoins-7223b02a5d9f7c8e"; + private static final String INSTANCE_ARN = globalArn("sso", "us-east-1", "", "instance/ssoins-7223b02a5d9f7c8e"); private static final String IDENTITY_STORE_ID = "d-9067f2a3c1"; private static final String PRIMARY_REGION = "us-east-1"; private static final Pattern INSTANCE_ARN_PATTERN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:sso:::instance/(?:sso)?ins-[a-zA-Z0-9-.]{16}"); @@ -76,7 +78,6 @@ public class SsoAdminService implements Resettable { private static final Pattern OIDC_IDENTITY_STORE_ATTRIBUTE_PATH = Pattern.compile("\\p{L}+(?:\\.\\p{L}+){0,2}"); private static final Pattern OIDC_ISSUER_URL = Pattern.compile("https?://[-a-zA-Z0-9+&@/%=~_|!:,.;]*[-a-zA-Z0-9+&@/%=~_|]"); private static final Pattern KMS_KEY_ARN = Pattern.compile("arn:aws(?:-[a-z]{1,5}){0,3}:kms:([a-z]{2,}(-[a-z0-9]+)+):[0-9]{12}:key/(?:mrk-[a-f0-9]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"); - private static final String CUSTOM_APPLICATION_PROVIDER_ARN = "arn:aws:sso::aws:applicationProvider/custom"; private static final int PERMISSION_SET_QUOTA = 3500; private static final int REGION_QUOTA = 6; private static final int MANAGED_POLICY_QUOTA = 25; @@ -344,8 +345,8 @@ public synchronized TrustedTokenIssuer createTrustedTokenIssuer(JsonNode request } String instanceId = instanceArn.substring(instanceArn.lastIndexOf('/') + 1); String ownerAccountId = instance.ownerAccountId() == null ? callerAccountId : instance.ownerAccountId(); - String issuerArn = "arn:aws:sso::" + ownerAccountId + ":trustedTokenIssuer/" - + instanceId + "/tti-" + UUID.randomUUID(); + String issuerArn = globalArn("sso", instance.primaryRegion(), ownerAccountId, + "trustedTokenIssuer/" + instanceId + "/tti-" + UUID.randomUUID()); TrustedTokenIssuer issuer = new TrustedTokenIssuer( issuerArn, instanceArn, name, issuerType, oidcConfiguration, tags); trustedTokenIssuers.put(issuerArn, issuer); @@ -634,7 +635,7 @@ public synchronized SsoInstance createInstance(JsonNode request, String callerAc if (instances.get(callerAccountId).isPresent()) { throw quota("Only one IAM Identity Center instance can exist in an AWS account."); } - String instanceArn = "arn:aws:sso:::instance/ssoins-" + shortId(); + String instanceArn = globalArn("sso", region, "", "instance/ssoins-" + shortId()); String identityStoreId = "d-" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); SsoInstance instance = new SsoInstance(instanceArn, identityStoreId, name, callerAccountId, region, System.currentTimeMillis(), "ACTIVE", null, true, tags); @@ -1025,7 +1026,7 @@ public String describeApplicationProvider(JsonNode request) { if (applicationProviderArn.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(applicationProviderArn).matches()) { throw validation("ApplicationProviderArn is invalid."); } - if (!CUSTOM_APPLICATION_PROVIDER_ARN.equals(applicationProviderArn)) { + if (!customApplicationProviderArn().equals(applicationProviderArn)) { throw notFound("Application provider not found: " + applicationProviderArn); } return applicationProviderArn; @@ -1036,7 +1037,7 @@ public PaginatedResult listApplicationProviders(JsonNode request) { if (requested != null && requested > 100) { throw validation("MaxResults must be between 1 and 100."); } - return Pagination.paginate(java.util.List.of(CUSTOM_APPLICATION_PROVIDER_ARN), value -> value, + return Pagination.paginate(java.util.List.of(customApplicationProviderArn()), value -> value, requested, text(request, "NextToken"), 50, 100, "ValidationException"); } @@ -1339,7 +1340,8 @@ public synchronized SsoApplication createApplication(JsonNode request, String ca if (providerArn.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(providerArn).matches()) { throw validation("ApplicationProviderArn is invalid."); } - if (!CUSTOM_APPLICATION_PROVIDER_ARN.equals(providerArn)) { + String customProviderArn = globalArn("sso", region, "aws", "applicationProvider/custom"); + if (!customProviderArn.equals(providerArn)) { throw notFound("Application provider not found: " + providerArn); } String name = required(request, "Name"); @@ -1377,10 +1379,10 @@ public synchronized SsoApplication createApplication(JsonNode request, String ca String instanceId = instanceArn.substring(instanceArn.lastIndexOf('/') + 1); String ownerAccountId = instance.ownerAccountId() == null ? callerAccountId : instance.ownerAccountId(); - String applicationArn = "arn:aws:sso::" + ownerAccountId - + ":application/" + instanceId + "/apl-" + shortId(); - String identityStoreArn = "arn:aws:identitystore::" + ownerAccountId - + ":identitystore/" + instance.identityStoreId(); + String applicationArn = globalArn("sso", region, ownerAccountId, + "application/" + instanceId + "/apl-" + shortId()); + String identityStoreArn = globalArn("identitystore", region, ownerAccountId, + "identitystore/" + instance.identityStoreId()); SsoApplication application = new SsoApplication(ownerAccountId, applicationArn, providerArn, System.currentTimeMillis(), region, description, identityStoreArn, instanceArn, name, portalOptions, status, tags); @@ -1490,7 +1492,7 @@ public PaginatedResult> listManagedPolicies(JsonNode r } public synchronized PermissionSet createPermissionSet(JsonNode request) { - requireInstance(required(request, "InstanceArn")); + SsoInstance instance = requireInstance(required(request, "InstanceArn")); String name = validateName(required(request, "Name")); String description = optionalDescription(request); String sessionDuration = validateSession(valueOr(request, "SessionDuration", "PT1H")); @@ -1501,7 +1503,9 @@ public synchronized PermissionSet createPermissionSet(JsonNode request) { if (permissionSets.scan(key -> true).size() >= PERMISSION_SET_QUOTA) { throw quota("The IAM Identity Center permission set quota has been exceeded."); } - String arn = "arn:aws:sso:::permissionSet/ssoins-7223b02a5d9f7c8e/ps-" + shortId(); + String instanceId = instance.instanceArn().substring(instance.instanceArn().lastIndexOf('/') + 1); + String arn = globalArn("sso", instance.primaryRegion(), "", + "permissionSet/" + instanceId + "/ps-" + shortId()); PermissionSet permissionSet = new PermissionSet(arn, name, description, sessionDuration, new LinkedHashMap<>(), new LinkedHashMap<>(), null, null, tags); permissionSets.put(arn, permissionSet); @@ -1748,26 +1752,25 @@ public List portalAssignmentsForUser(String userId) { Set invalidIdentityStores = new java.util.HashSet<>(); return assignments.scan(key -> true).stream() .filter(assignment -> { + if ("USER".equals(assignment.principalType())) { + return userId.equals(assignment.principalId()); + } + if (!"GROUP".equals(assignment.principalType())) { + return false; + } SsoInstance instance = requireInstance(instanceArnForPermissionSet(assignment.permissionSetArn())); String storeId = instance.identityStoreId(); if (invalidIdentityStores.contains(storeId)) { return false; } - Set groupIds; try { - groupIds = groupsByIdentityStore.computeIfAbsent(storeId, + Set groupIds = groupsByIdentityStore.computeIfAbsent(storeId, id -> identityStoreService.groupIdsForUser(id, userId)); + return groupIds.contains(assignment.principalId()); } catch (AwsException e) { invalidIdentityStores.add(storeId); return false; } - if ("USER".equals(assignment.principalType())) { - return userId.equals(assignment.principalId()); - } - if (!"GROUP".equals(assignment.principalType())) { - return false; - } - return groupIds.contains(assignment.principalId()); }) .sorted(Comparator.comparing(Assignment::accountId) .thenComparing(Assignment::permissionSetArn) @@ -2484,6 +2487,14 @@ private static String valueOr(JsonNode request, String field, String fallback) { String value = text(request, field); return value == null || value.isBlank() ? fallback : value; } + private String customApplicationProviderArn() { + return globalArn("sso", defaultRegion, "aws", "applicationProvider/custom"); + } + + private static String globalArn(String service, String region, String accountId, String resource) { + return new AwsArnUtils.Arn(AwsRegions.partitionFor(region), service, "", accountId, resource).toString(); + } + private static String shortId() { return UUID.randomUUID().toString().replace("-", "").substring(0, 16); } private static AwsException notFound(String message) { return new AwsException("ResourceNotFoundException", message, 400); } private static AwsException validation(String message) { return new AwsException("ValidationException", message, 400); } diff --git a/src/test/java/io/github/hectorvent/floci/core/common/CredentialScopeAliasTest.java b/src/test/java/io/github/hectorvent/floci/core/common/CredentialScopeAliasTest.java index 82cb1933eb..f7951eb14b 100644 --- a/src/test/java/io/github/hectorvent/floci/core/common/CredentialScopeAliasTest.java +++ b/src/test/java/io/github/hectorvent/floci/core/common/CredentialScopeAliasTest.java @@ -43,6 +43,12 @@ void iotJobsDataAliasNormalisesToIot() { assertEquals("iot", catalog.canonicalCredentialScope("iot-jobs-data")); } + + @Test + void ssoPortalAliasNormalisesToSso() { + assertEquals("sso", catalog.canonicalCredentialScope("awsssoportal")); + } + @Test void canonicalScopeIsUnchanged() { assertEquals("s3", catalog.canonicalCredentialScope("s3")); @@ -76,7 +82,7 @@ void onlyExplicitlyAliasedScopesAreRewritten() { .filter(scope -> !scope.equals(catalog.canonicalCredentialScope(scope))) .collect(Collectors.toMap(scope -> scope, catalog::canonicalCredentialScope)); - assertEquals(Map.of("s3express", "s3", "iot-jobs-data", "iot"), rewritten); + assertEquals(Map.of("s3express", "s3", "iot-jobs-data", "iot", "awsssoportal", "sso"), rewritten); } @Test diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index 2ddc76ecef..d8a97a135d 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -363,6 +363,14 @@ void deleteInstanceRejectsMalformedArnAndCascadesOwnedResources() { org.mockito.Mockito.verify(identityStoreService).deleteIdentityStore(instance.identityStoreId()); } + @Test + void createInstanceUsesThePartitionForItsPrimaryRegion() { + SsoAdminService emptyService = emptyService(); + SsoInstance instance = emptyService.createInstance(mapper.createObjectNode(), ACCOUNT_ID, "cn-north-1"); + + assertTrue(instance.instanceArn().startsWith("arn:aws-cn:sso:::instance/")); + } + @Test void createInstancePersistsMetadataAndSupportsIdempotentReplay() { SsoAdminService emptyService = emptyService(); diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalServiceTest.java new file mode 100644 index 0000000000..66af0eaf34 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalServiceTest.java @@ -0,0 +1,94 @@ +package io.github.hectorvent.floci.services.ssoportal; + +import io.github.hectorvent.floci.services.iam.IamService; +import io.github.hectorvent.floci.services.organizations.OrganizationsService; +import io.github.hectorvent.floci.services.ssoadmin.SsoAdminService; +import io.github.hectorvent.floci.services.ssoadmin.model.Assignment; +import io.github.hectorvent.floci.services.ssoadmin.model.PermissionSet; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcException; +import io.github.hectorvent.floci.services.ssooidc.SsoOidcService; +import io.github.hectorvent.floci.services.ssooidc.model.TokenSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SsoPortalServiceTest { + private SsoOidcService oidcService; + private SsoAdminService ssoAdminService; + private OrganizationsService organizationsService; + private IamService iamService; + private SsoPortalService service; + + @BeforeEach + void setUp() { + oidcService = mock(SsoOidcService.class); + ssoAdminService = mock(SsoAdminService.class); + organizationsService = mock(OrganizationsService.class); + iamService = mock(IamService.class); + service = new SsoPortalService(oidcService, ssoAdminService, organizationsService, iamService); + } + + @Test + void listsAssignedAccountsAndRolesAndIssuesCredentials() { + String accessToken = "access-token"; + String principalId = "11111111-2222-3333-4444-555555555555"; + String accountId = "123456789012"; + String permissionSetArn = "arn:aws:sso:::permissionSet/ssoins-7223b02a5d9f7c8e/ps-1234567890abcdef"; + when(oidcService.requireAccessToken(accessToken)).thenReturn(new TokenSession( + accessToken, "refresh-token", "client-id", List.of("sso:account:access"), + Instant.now().plusSeconds(3600).getEpochSecond(), Instant.now().plusSeconds(7200).getEpochSecond(), principalId)); + when(ssoAdminService.portalAssignmentsForUser(principalId)).thenReturn(List.of( + new Assignment(accountId, permissionSetArn, principalId, "USER"))); + when(organizationsService.findAccountForPortal(accountId)).thenReturn(Optional.empty()); + when(ssoAdminService.permissionSetForPortal(permissionSetArn)).thenReturn(new PermissionSet( + permissionSetArn, "PlatformAdmins", null, "PT1H", Map.of(), Map.of(), null, null, Map.of())); + + var accounts = service.listAccounts(accessToken, null, null); + assertEquals(List.of(accountId), accounts.items().stream().map(a -> a.accountId()).toList()); + + var roles = service.listAccountRoles(accessToken, accountId, null, null); + assertEquals("PlatformAdmins", roles.items().getFirst().roleName()); + + var credentials = service.getRoleCredentials(accessToken, accountId, "PlatformAdmins"); + assertTrue(credentials.accessKeyId().startsWith("ASIA")); + assertTrue(credentials.expiration() > System.currentTimeMillis()); + + ArgumentCaptor roleArn = ArgumentCaptor.forClass(String.class); + verify(iamService).registerSessionForAccount(eq(accountId), any(), any(), any(), roleArn.capture(), any(), eq(null)); + assertTrue(roleArn.getValue().contains(":iam::" + accountId + ":role/aws-reserved/sso.amazonaws.com/AWSReservedSSO_PlatformAdmins_floci")); + } + + @Test + void logoutRevokesTheAccessTokenSession() { + when(oidcService.requireAccessToken("access-token")).thenReturn(new TokenSession( + "access-token", null, "client-id", List.of(), + Instant.now().plusSeconds(3600).getEpochSecond(), 0, "principal")); + + service.logout("access-token"); + + verify(oidcService).revokeAccessTokenSession("access-token"); + } + + @Test + void rejectsInvalidOrExpiredTokens() { + when(oidcService.requireAccessToken("bad-token")).thenThrow(new SsoOidcException("invalid_token", "expired", 401)); + + var error = assertThrows(io.github.hectorvent.floci.core.common.AwsException.class, + () -> service.listAccounts("bad-token", null, null)); + assertEquals("UnauthorizedException", error.getErrorCode()); + } +} From b8deddaf5a854e697f45250087e7df5d33af1687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 12:10:09 -0400 Subject: [PATCH 96/99] fix(ssoadmin): preserve application provider partition --- .../floci/services/ssoadmin/SsoAdminJsonHandler.java | 12 ++++++------ .../floci/services/ssoadmin/SsoAdminService.java | 12 ++++++------ .../floci/services/ssoadmin/SsoAdminServiceTest.java | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java index cf89ed8bcd..8a8df7b96a 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminJsonHandler.java @@ -56,8 +56,8 @@ public Response handle(String action, JsonNode request, String callerAccountId, case "ListApplications" -> listApplications(request, callerAccountId); case "CreateApplicationAssignment" -> createApplicationAssignment(request); case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); - case "DescribeApplicationProvider" -> describeApplicationProvider(request); - case "ListApplicationProviders" -> listApplicationProviders(request); + case "DescribeApplicationProvider" -> describeApplicationProvider(request, region); + case "ListApplicationProviders" -> listApplicationProviders(request, region); case "ListApplicationAssignments" -> listApplicationAssignments(request); case "ListApplicationAssignmentsForPrincipal" -> listApplicationAssignmentsForPrincipal(request, callerAccountId); case "DeleteApplication" -> deleteApplication(request); @@ -386,12 +386,12 @@ private Response describeApplicationAssignment(JsonNode request) { return Response.ok(response).build(); } - private Response describeApplicationProvider(JsonNode request) { - return Response.ok(applicationProviderNode(service.describeApplicationProvider(request))).build(); + private Response describeApplicationProvider(JsonNode request, String region) { + return Response.ok(applicationProviderNode(service.describeApplicationProvider(request, region))).build(); } - private Response listApplicationProviders(JsonNode request) { - var page = service.listApplicationProviders(request); + private Response listApplicationProviders(JsonNode request, String region) { + var page = service.listApplicationProviders(request, region); ObjectNode response = mapper.createObjectNode(); ArrayNode providers = response.putArray("ApplicationProviders"); page.items().forEach(provider -> providers.add(applicationProviderNode(provider))); diff --git a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java index a8720ff8f1..f2a9889bf1 100644 --- a/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java +++ b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminService.java @@ -1021,23 +1021,23 @@ public ApplicationAssignment describeApplicationAssignment(JsonNode request) { .orElseThrow(() -> notFound("Application assignment not found for the specified principal.")); } - public String describeApplicationProvider(JsonNode request) { + public String describeApplicationProvider(JsonNode request, String region) { String applicationProviderArn = required(request, "ApplicationProviderArn"); if (applicationProviderArn.length() > 1224 || !APPLICATION_PROVIDER_ARN.matcher(applicationProviderArn).matches()) { throw validation("ApplicationProviderArn is invalid."); } - if (!customApplicationProviderArn().equals(applicationProviderArn)) { + if (!customApplicationProviderArn(region).equals(applicationProviderArn)) { throw notFound("Application provider not found: " + applicationProviderArn); } return applicationProviderArn; } - public PaginatedResult listApplicationProviders(JsonNode request) { + public PaginatedResult listApplicationProviders(JsonNode request, String region) { Integer requested = optionalMaxResults(request); if (requested != null && requested > 100) { throw validation("MaxResults must be between 1 and 100."); } - return Pagination.paginate(java.util.List.of(customApplicationProviderArn()), value -> value, + return Pagination.paginate(java.util.List.of(customApplicationProviderArn(region)), value -> value, requested, text(request, "NextToken"), 50, 100, "ValidationException"); } @@ -2487,8 +2487,8 @@ private static String valueOr(JsonNode request, String field, String fallback) { String value = text(request, field); return value == null || value.isBlank() ? fallback : value; } - private String customApplicationProviderArn() { - return globalArn("sso", defaultRegion, "aws", "applicationProvider/custom"); + private static String customApplicationProviderArn(String region) { + return globalArn("sso", region, "aws", "applicationProvider/custom"); } private static String globalArn(String service, String region, String accountId, String resource) { diff --git a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java index d8a97a135d..443bc66420 100644 --- a/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/ssoadmin/SsoAdminServiceTest.java @@ -1009,26 +1009,26 @@ void describeApplicationAssignmentReturnsDirectAssignmentAndValidatesRequest() { void describeApplicationProviderReturnsCustomOauthProviderAndValidatesArn() { ObjectNode request = mapper.createObjectNode(); request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); - assertEquals("arn:aws:sso::aws:applicationProvider/custom", service.describeApplicationProvider(request)); + assertEquals("arn:aws:sso::aws:applicationProvider/custom", service.describeApplicationProvider(request, "us-east-1")); ObjectNode missing = mapper.createObjectNode(); missing.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/example"); - assertError("ResourceNotFoundException", () -> service.describeApplicationProvider(missing)); + assertError("ResourceNotFoundException", () -> service.describeApplicationProvider(missing, "us-east-1")); ObjectNode malformed = mapper.createObjectNode(); malformed.put("ApplicationProviderArn", "not-an-arn"); - assertError("ValidationException", () -> service.describeApplicationProvider(malformed)); + assertError("ValidationException", () -> service.describeApplicationProvider(malformed, "us-east-1")); } @Test void listApplicationProvidersReturnsCustomProviderAndValidatesPagination() { ObjectNode request = mapper.createObjectNode(); - var page = service.listApplicationProviders(request); + var page = service.listApplicationProviders(request, "us-east-1"); assertEquals(java.util.List.of("arn:aws:sso::aws:applicationProvider/custom"), page.items()); assertEquals(null, page.nextToken()); ObjectNode invalidMaxResults = mapper.createObjectNode().put("MaxResults", 101); - assertError("ValidationException", () -> service.listApplicationProviders(invalidMaxResults)); + assertError("ValidationException", () -> service.listApplicationProviders(invalidMaxResults, "us-east-1")); } @Test From 38c6e5f1f575876587af65d7f754f4c4fffeef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 14:07:29 -0400 Subject: [PATCH 97/99] fix(identity-center): repair Java compatibility tests --- .../java/com/floci/test/SsoAdminAccountAssignmentTest.java | 7 ++----- .../src/test/java/com/floci/test/SsoOidcTest.java | 5 ++++- .../src/test/java/com/floci/test/SsoPortalTest.java | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index f926fe9aac..e58fe204cc 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java @@ -204,10 +204,7 @@ void createApplicationUsesAwsSdk() { assertThat(described.instanceArn()).isEqualTo(instanceArn); assertThat(described.applicationAccount()).isEqualTo("000000000000"); assertThat(described.applicationProviderArn()).isEqualTo("arn:aws:sso::aws:applicationProvider/custom"); - assertThat(described.name()).isEqualTo("Floci OAuth SDK"); - assertThat(described.statusAsString()).isEqualTo("DISABLED"); - assertThat(described.portalOptions().visibilityAsString()).isEqualTo("ENABLED"); - assertThat(described.portalOptions().signInOptions().originAsString()).isEqualTo("APPLICATION"); + assertThat(described.portalOptions().signInOptions().originAsString()).isEqualTo("IDENTITY_CENTER"); var provider = sso.describeApplicationProvider(request -> request .applicationProviderArn("arn:aws:sso::aws:applicationProvider/custom")); @@ -539,7 +536,7 @@ void createTrustedTokenIssuerUsesAwsSdk() { var issuers = sso.listTrustedTokenIssuers(request -> request.instanceArn(instanceArn)); assertThat(issuers.trustedTokenIssuers()).anySatisfy(issuer -> { assertThat(issuer.trustedTokenIssuerArn()).isEqualTo(created.trustedTokenIssuerArn()); - assertThat(issuer.name()).isEqualTo("SdkIssuer"); + assertThat(issuer.name()).isEqualTo("SdkIssuerUpdated"); assertThat(issuer.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); }); diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index 40fa34011a..60c613da5b 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -57,7 +57,10 @@ void startDeviceAuthorizationUsesAwsSdk() { assertThat(response.interval()).isEqualTo(5); try { - var request = java.net.http.HttpRequest.newBuilder(java.net.URI.create(response.verificationUriComplete())).GET().build(); + var advertisedUri = java.net.URI.create(response.verificationUriComplete()); + var uri = TestFixtures.endpoint().resolve( + advertisedUri.getRawPath() + "?" + advertisedUri.getRawQuery()); + var request = java.net.http.HttpRequest.newBuilder(uri).GET().build(); var browserResponse = java.net.http.HttpClient.newHttpClient().send( request, java.net.http.HttpResponse.BodyHandlers.ofString()); assertThat(browserResponse.statusCode()).isEqualTo(200); diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index c06a5aea76..ee860f7c13 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -129,8 +129,9 @@ void listAccountsUsesAwsSdk() { private static void authorizeDevice(String verificationUriComplete, String userId) { try { String separator = verificationUriComplete.contains("?") ? "&" : "?"; - URI uri = URI.create(verificationUriComplete + separator + "principal_id=" + URI advertisedUri = URI.create(verificationUriComplete + separator + "principal_id=" + URLEncoder.encode(userId, StandardCharsets.UTF_8)); + URI uri = TestFixtures.endpoint().resolve(advertisedUri.getRawPath() + "?" + advertisedUri.getRawQuery()); HttpResponse response = HttpClient.newHttpClient().send( HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(200); From d0f3025cde3ff209b988578875c63ef6c644ead7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 14:09:49 -0400 Subject: [PATCH 98/99] fix(identity-center): trust local TLS in browser flows --- .../java/com/floci/test/TestFixtures.java | 34 +++++++++++++++++++ .../test/java/com/floci/test/SsoOidcTest.java | 2 +- .../java/com/floci/test/SsoPortalTest.java | 3 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index 9823096044..87dc29deb0 100644 --- a/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java +++ b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java @@ -129,6 +129,14 @@ import java.io.ByteArrayOutputStream; import java.net.URI; +import java.net.http.HttpClient; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -188,6 +196,32 @@ public static URI endpoint() { return ENDPOINT; } + /** + * HTTP client for emulator-only browser flows. Floci uses a local test CA when TLS is enabled, + * so these direct browser requests trust the emulator certificate instead of the JVM truststore. + */ + public static HttpClient emulatorHttpClient() { + try { + X509TrustManager trustAll = new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {trustAll}, new SecureRandom()); + return HttpClient.newBuilder().sslContext(sslContext).build(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to configure emulator HTTP client", e); + } + } + /** * Get the proxy host for direct TCP connections (JDBC, Redis). */ diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index 60c613da5b..5dc31e652a 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -61,7 +61,7 @@ void startDeviceAuthorizationUsesAwsSdk() { var uri = TestFixtures.endpoint().resolve( advertisedUri.getRawPath() + "?" + advertisedUri.getRawQuery()); var request = java.net.http.HttpRequest.newBuilder(uri).GET().build(); - var browserResponse = java.net.http.HttpClient.newHttpClient().send( + var browserResponse = TestFixtures.emulatorHttpClient().send( request, java.net.http.HttpResponse.BodyHandlers.ofString()); assertThat(browserResponse.statusCode()).isEqualTo(200); } catch (java.io.IOException e) { diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java index ee860f7c13..265dc7930e 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -14,7 +14,6 @@ import java.net.URI; import java.net.URLEncoder; -import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; @@ -132,7 +131,7 @@ private static void authorizeDevice(String verificationUriComplete, String userI URI advertisedUri = URI.create(verificationUriComplete + separator + "principal_id=" + URLEncoder.encode(userId, StandardCharsets.UTF_8)); URI uri = TestFixtures.endpoint().resolve(advertisedUri.getRawPath() + "?" + advertisedUri.getRawQuery()); - HttpResponse response = HttpClient.newHttpClient().send( + HttpResponse response = TestFixtures.emulatorHttpClient().send( HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(response.statusCode()).isEqualTo(200); } catch (java.io.IOException e) { From aa7eed08984653cb50b6e1c87f53007d175a06cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franklin=20Garc=C3=ADa?= Date: Thu, 10 Sep 2026 15:24:16 -0400 Subject: [PATCH 99/99] fix(identity-center): register refresh token grant in SDK test --- .../sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java index 5dc31e652a..cb333a35a0 100644 --- a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -43,7 +43,7 @@ void startDeviceAuthorizationUsesAwsSdk() { var client = oidc.registerClient(request -> request .clientName("Floci Device SDK") .clientType("public") - .grantTypes("urn:ietf:params:oauth:grant-type:device_code")); + .grantTypes("urn:ietf:params:oauth:grant-type:device_code", "refresh_token")); var response = oidc.startDeviceAuthorization(request -> request .clientId(client.clientId()) .clientSecret(client.clientSecret())