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/main/java/com/floci/test/TestFixtures.java b/compatibility-tests/sdk-test-java/src/main/java/com/floci/test/TestFixtures.java index a74b9b0db9..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 @@ -33,7 +33,9 @@ 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; import software.amazon.awssdk.services.budgets.BudgetsClient; import software.amazon.awssdk.services.macie2.Macie2Client; @@ -127,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; @@ -186,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). */ @@ -317,6 +353,30 @@ 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 SsoOidcClient ssoOidcClient() { + return SsoOidcClient.builder() + .endpointOverride(ENDPOINT) + .region(REGION) + .credentialsProvider(CREDENTIALS) + .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/SsoAdminAccountAssignmentTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoAdminAccountAssignmentTest.java index 1c8fd42c6f..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 @@ -14,6 +14,976 @@ @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"); + + 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(); + + 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"); + }); + + 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"))) + .isInstanceOf(software.amazon.awssdk.services.ssoadmin.model.ConflictException.class); + } + } + + @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(); + 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) + .customerManagedPolicyReference(reference -> reference + .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); + } + } + + @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()); + + 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 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 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") + .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 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"); + assertThat(described.portalOptions().signInOptions().originAsString()).isEqualTo("IDENTITY_CENTER"); + + 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"); + + 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 + .applicationAccount("000000000000") + .applicationProvider("arn:aws:sso::aws:applicationProvider/custom"))); + 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 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()) + .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); + + 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); + } + } + + @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(); + + 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"); + + 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") + .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"); + }); + } + } + + @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("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(); + + 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"); + 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) + .authenticationMethodType("IAM")); + 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"))) + .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("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(); + + 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) + .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"); + 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")); + 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"))) + .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() { + 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()); + + 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("SdkIssuerUpdated"); + assertThat(described.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); + 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("SdkIssuerUpdated"); + assertThat(issuer.trustedTokenIssuerTypeAsString()).isEqualTo("OIDC_JWT"); + }); + + 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); + } + } + + @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(); + + 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 + .accessControlAttributes(attribute -> attribute + .key("Department") + .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(); + + 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(); + } + } + + @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 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(); + 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); + 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("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() { + 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() { + 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("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("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(); + + 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"); + + var listed = sso.listPermissionSetProvisioningStatus(request -> request + .instanceArn(instanceArn) + .filter(filter -> filter.status("SUCCEEDED"))); + assertThat(listed.permissionSetsProvisioningStatus()) + .anyMatch(status -> response.permissionSetProvisioningStatus().requestId().equals(status.requestId())); + } + } + + @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("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("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("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(); + var boundary = sso.getPermissionsBoundaryForPermissionSet(request -> request + .instanceArn(instanceArn) + .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); + } + } + + @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() { + 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(); + 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"); + + 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") + .permissionSetArn(permissionSetArn)).accountAssignments()).isEmpty(); + } + } + @Test @DisplayName("creates and describes account assignments through the AWS SDK") void accountAssignmentLifecycleUsesAwsSdk() { @@ -42,6 +1012,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/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..cb333a35a0 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoOidcTest.java @@ -0,0 +1,185 @@ +package com.floci.test; + +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; +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"); + } + } + + @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", "refresh_token")); + 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); + + try { + 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 = TestFixtures.emulatorHttpClient().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()); + } + } + + @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/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..265dc7930e --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/SsoPortalTest.java @@ -0,0 +1,144 @@ +package com.floci.test; + +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.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 software.amazon.awssdk.services.sts.StsClient; + +import java.net.URI; +import java.net.URLEncoder; +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(); + 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 = "11111111-2222-3333-4444-555555555555"; + 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); + + 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); + + 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); + + 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); + } + } + + private static void authorizeDevice(String verificationUriComplete, String userId) { + try { + String separator = verificationUriComplete.contains("?") ? "&" : "?"; + 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 = TestFixtures.emulatorHttpClient().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/identitystore.md b/docs/services/identitystore.md index bc30b9b69e..f11578ee9d 100644 --- a/docs/services/identitystore.md +++ b/docs/services/identitystore.md @@ -42,6 +42,42 @@ 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, 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. + +`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. + +`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`. + +`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. + +`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. + +`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. + +`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. + +`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 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/docs/services/index.md b/docs/services/index.md index b5d2e020d2..5fd8dd7002 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -46,7 +46,9 @@ 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 | | [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/ssoadmin.md b/docs/services/ssoadmin.md index b9f7612bb9..9e524a5dd1 100644 --- a/docs/services/ssoadmin.md +++ b/docs/services/ssoadmin.md @@ -11,18 +11,84 @@ 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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | | `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. | | `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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/docs/services/ssooidc.md b/docs/services/ssooidc.md new file mode 100644 index 0000000000..be623ab05b --- /dev/null +++ b/docs/services/ssooidc.md @@ -0,0 +1,36 @@ +# 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. | +| `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. + +`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. 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. + +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/docs/services/ssoportal.md b/docs/services/ssoportal.md new file mode 100644 index 0000000000..76046f2061 --- /dev/null +++ b/docs/services/ssoportal.md @@ -0,0 +1,38 @@ +# 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. | +| `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. + +`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. + +`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). + +## 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/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/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java b/src/main/java/io/github/hectorvent/floci/config/EmulatorConfig.java index 926d7613d6..3a0201017c 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,13 @@ interface SsoAdminServiceConfig { boolean enabled(); } + interface SsoOidcServiceConfig { + @WithDefault("true") + boolean enabled(); + + Optional localPrincipalId(); + } + interface Macie2ServiceConfig { @WithDefault("true") boolean enabled(); @@ -780,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/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/core/common/ResolvedServiceCatalog.java b/src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java index 521899d049..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 @@ -40,6 +40,8 @@ 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.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; @@ -72,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; @@ -424,8 +427,12 @@ 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", "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), + 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/identitystore/IdentityStoreService.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/IdentityStoreService.java index 80102d5b04..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 @@ -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")); @@ -167,6 +177,32 @@ public PaginatedResult listUsers(JsonNode request) { optionalMaxResults(request), text(request, "NextToken"), 50, 100, "ValidationException"); } + 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")); @@ -287,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."); @@ -301,6 +348,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/identitystore/ScimController.java b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java new file mode 100644 index 0000000000..21140ead48 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/identitystore/ScimController.java @@ -0,0 +1,1461 @@ +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.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; +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; +import jakarta.ws.rs.Consumes; +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.PUT; +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.security.MessageDigest; +import java.util.Base64; +import java.util.List; +import java.util.Map; +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 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 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 \\\"([^\\\"]*)\\\"$"); + 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 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); + 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; + private final byte[] scimBearerToken; + + @Inject + 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 + @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, + @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, + @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, + @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, + @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, + @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") + 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, + @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, + @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()); + } + } + + @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, + @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, + @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()); + } + } + + @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, + @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(scimStatus(exception), exception.getMessage()); + } + } + + @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(scimStatus(exception), exception.getMessage()); + } + } + + 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."); + } + 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()); + response.putArray("members"); + String externalId = scimExternalId(group.attributes().get("ExternalIds"), GROUP_SCHEMA); + 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 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."); + } + } + + 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 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 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 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); + 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(); + 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(); + 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()) { + 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()) { + 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()) { + 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, String issuer) { + if (externalIds == null || !externalIds.isArray()) { + return null; + } + for (JsonNode externalId : externalIds) { + if (issuer.equals(optionalText(externalId, "Issuer"))) { + return optionalText(externalId, "Id"); + } + } + 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"); + 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/organizations/OrganizationsService.java b/src/main/java/io/github/hectorvent/floci/services/organizations/OrganizationsService.java index bf47793626..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() @@ -1350,6 +1359,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 fd5ffee22c..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 @@ -5,9 +5,13 @@ 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; 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; import jakarta.ws.rs.core.Response; @@ -23,36 +27,548 @@ 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 "CreateInstance" -> createInstance(request, callerAccountId, region); + case "UpdateInstance" -> updateInstance(request, callerAccountId); + case "DescribeInstance" -> describeInstance(request); + 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 "UpdateTrustedTokenIssuer" -> updateTrustedTokenIssuer(request); + case "DescribeTrustedTokenIssuer" -> describeTrustedTokenIssuer(request); + 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); + 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); + case "DescribeApplicationAssignment" -> describeApplicationAssignment(request); + case "DescribeApplicationProvider" -> describeApplicationProvider(request, region); + case "ListApplicationProviders" -> listApplicationProviders(request, region); + case "ListApplicationAssignments" -> listApplicationAssignments(request); + 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); + 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 "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); case "CreatePermissionSet" -> createPermissionSet(request); + case "DeletePermissionSet" -> deletePermissionSet(request); case "DescribePermissionSet" -> describePermissionSet(request); case "UpdatePermissionSet" -> updatePermissionSet(request); case "ListManagedPoliciesInPermissionSet" -> listManagedPolicies(request); 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 "DeletePermissionsBoundaryFromPermissionSet" -> deletePermissionsBoundary(request); + case "GetInlinePolicyForPermissionSet" -> getInlinePolicy(request); + case "GetPermissionsBoundaryForPermissionSet" -> getPermissionsBoundary(request); case "PutInlinePolicyToPermissionSet" -> putInlinePolicy(request); + case "PutPermissionsBoundaryToPermissionSet" -> putPermissionsBoundary(request); case "ListAccountAssignments" -> listAccountAssignments(request); + 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); + case "DeleteAccountAssignment" -> deleteAccountAssignment(request); 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); }; } 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()); + 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); + 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 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 (updateState.name() != null) { + response.put("Name", updateState.name()); + } + response.put("OwnerAccountId", instance.ownerAccountId()); + 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()); + } + return Response.ok(response).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(); + } + + 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 updateInstanceAccessControlAttributeConfiguration(JsonNode request) { + service.updateInstanceAccessControlAttributeConfiguration(request); + 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() + .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); + 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()); + 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(); + } + + private Response addRegion(JsonNode request) { + var region = service.addRegion(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(); + } + + 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; + } + + 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 tagResource(JsonNode request) { + service.tagResource(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(); + } + + private Response getApplicationAssignmentConfiguration(JsonNode request) { + return Response.ok(mapper.createObjectNode() + .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(); + } + + 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 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(); + } + + private Response createApplicationAssignment(JsonNode request) { + service.createApplicationAssignment(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 describeApplicationProvider(JsonNode request, String region) { + return Response.ok(applicationProviderNode(service.describeApplicationProvider(request, region))).build(); + } + + 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))); + 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; + } + + 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 -> { + 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(); + } + + 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 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(); + } + + private Response deleteApplicationAuthenticationMethod(JsonNode request) { + service.deleteApplicationAuthenticationMethod(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 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 getApplicationSessionConfiguration(JsonNode request) { + return Response.ok(mapper.createObjectNode().put("UserBackgroundSessionApplicationStatus", + 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(); + } + + 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(); + response.put("ApplicationArn", application.applicationArn()); + response.put("IdentityStoreArn", application.identityStoreArn()); + response.put("InstanceArn", application.instanceArn()); + 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(); + } + + 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()); + 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; + } + private Response listPermissionSets(JsonNode request) { var page = service.listPermissionSets(request); ObjectNode response = mapper.createObjectNode(); @@ -71,6 +587,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")); @@ -102,6 +625,29 @@ 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 detachCustomerManagedPolicyReference(JsonNode request) { + service.detachCustomerManagedPolicyReference(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")); @@ -113,12 +659,49 @@ 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"), + SsoAdminService.required(request, "PermissionSetArn")); + ObjectNode response = mapper.createObjectNode(); + response.put("InlinePolicy", permissionSet.inlinePolicy() == null ? "" : permissionSet.inlinePolicy()); + 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")); 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(); @@ -133,6 +716,99 @@ 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 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 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 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(); + 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 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(); @@ -140,6 +816,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"), @@ -149,6 +843,58 @@ 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"), + 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 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()); @@ -163,6 +909,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 d482ee0766..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 @@ -2,19 +2,45 @@ 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; +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; 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.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; +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; +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; +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; import java.time.Duration; +import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; @@ -25,14 +51,42 @@ @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}"); 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 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 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}_.:/=+\\-@]*"); + 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}_.:/=+\\-@\\[\\]\\{\\}$\\\\\"]*"); + 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 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 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"); @@ -40,26 +94,1382 @@ public class SsoAdminService implements Resettable { private final StorageBackend permissionSets; 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 applicationUpdateOverrides; + 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 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; + private final StorageBackend trustedTokenIssuers; + private final StorageBackend trustedTokenIssuerUpdateOverrides; + 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) { + 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>() {}), - storageFactory.create("ssoadmin", "ssoadmin-assignment-operations.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-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>() {}), + 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-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>() {}), + 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, + config.defaultAccountId(), + config.defaultRegion()); } SsoAdminService(StorageBackend permissionSets, StorageBackend assignments, - StorageBackend assignmentOperations) { + StorageBackend assignmentOperations, + StorageBackend assignmentDeletionOperations, + StorageBackend permissionSetProvisionings, + StorageBackend permissionSetProvisioningOperations, + StorageBackend regions, + StorageBackend applications, + StorageBackend applicationUpdateOverrides, + StorageBackend applicationClientTokens, + StorageBackend applicationAssignments, + StorageBackend applicationAccessScopes, + StorageBackend applicationAssignmentConfigurations, + StorageBackend applicationAuthenticationMethods, + StorageBackend applicationGrants, + StorageBackend applicationSessionConfigurations, + StorageBackend> resourceTagOverrides, + StorageBackend instances, + StorageBackend instanceUpdateStates, + StorageBackend instanceClientTokens, + StorageBackend instanceDeletionMarkers, + StorageBackend accessControlAttributeConfigurations, + StorageBackend trustedTokenIssuers, + StorageBackend trustedTokenIssuerUpdateOverrides, + StorageBackend trustedTokenIssuerClientTokens, + IdentityStoreService identityStoreService, + OrganizationsService organizationsService, + String defaultAccountId, + String defaultRegion) { this.permissionSets = permissionSets; this.assignments = assignments; this.assignmentOperations = assignmentOperations; + this.assignmentDeletionOperations = assignmentDeletionOperations; + this.permissionSetProvisionings = permissionSetProvisionings; + this.permissionSetProvisioningOperations = permissionSetProvisioningOperations; + this.regions = regions; + this.applications = applications; + this.applicationUpdateOverrides = applicationUpdateOverrides; + this.applicationClientTokens = applicationClientTokens; + this.applicationAssignments = applicationAssignments; + this.applicationAccessScopes = applicationAccessScopes; + this.applicationAssignmentConfigurations = applicationAssignmentConfigurations; + this.applicationAuthenticationMethods = applicationAuthenticationMethods; + this.applicationGrants = applicationGrants; + this.applicationSessionConfigurations = applicationSessionConfigurations; + this.resourceTagOverrides = resourceTagOverrides; + this.instances = instances; + this.instanceUpdateStates = instanceUpdateStates; + this.instanceClientTokens = instanceClientTokens; + this.instanceDeletionMarkers = instanceDeletionMarkers; + this.accessControlAttributeConfigurations = accessControlAttributeConfigurations; + this.trustedTokenIssuers = trustedTokenIssuers; + this.trustedTokenIssuerUpdateOverrides = trustedTokenIssuerUpdateOverrides; + this.trustedTokenIssuerClientTokens = trustedTokenIssuerClientTokens; + this.identityStoreService = identityStoreService; + 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; } + public boolean hasIdentityStore(String identityStoreId) { + if (identityStoreId == null) { + return false; + } + 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) { + 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, + 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 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 = globalArn("sso", instance.primaryRegion(), 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 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() + .map(issuer -> trustedTokenIssuerUpdateOverrides.get(issuer.trustedTokenIssuerArn()).orElse(issuer)) + .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 PaginatedResult> listTagsForResource(JsonNode request) { + String instanceArn = optionalInstanceArn(request); + String resourceArn = required(request, "ResourceArn"); + 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); + } + + 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."); + } + if (INSTANCE_ARN_PATTERN.matcher(resourceArn).matches()) { + SsoInstance instance = requireInstance(resourceArn); + 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)); + return new TaggableResource(permissionSet.tags(), instanceArnForPermissionSet(resourceArn)); + } + if (APPLICATION_ARN.matcher(resourceArn).matches()) { + SsoApplication application = getApplication(resourceArn); + return new TaggableResource(application.tags(), application.instanceArn()); + } + if (TRUSTED_TOKEN_ISSUER_ARN.matcher(resourceArn).matches()) { + TrustedTokenIssuer issuer = getTrustedTokenIssuer(resourceArn); + return new TaggableResource(issuer.tags(), issuer.instanceArn()); + } + 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."); + } + } + + private record TaggableResource(Map tags, String instanceArn) {} + + public TrustedTokenIssuer getTrustedTokenIssuer(String trustedTokenIssuerArn) { + validateTrustedTokenIssuerArn(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))) { + 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) { + 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); + if (accessControlAttributeConfigurations.get(instanceArn).isPresent()) { + throw conflict("An instance access control attribute configuration already exists for this IAM Identity Center instance."); + } + 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")); + } + + public InstanceAccessControlAttributeConfiguration getInstanceAccessControlAttributeConfiguration(String instanceArn) { + requireInstance(instanceArn); + return accessControlAttributeConfigurations.get(instanceArn) + .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); + 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 = 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); + instances.put(callerAccountId, instance); + instanceDeletionMarkers.delete(callerAccountId); + instanceClientTokens.put(tokenKey, instanceArn); + return instance; + } + + 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"); + 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); + } + 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); + } + 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); + if (issuer != null && instanceArn.equals(issuer.instanceArn())) { + trustedTokenIssuers.delete(key); + trustedTokenIssuerUpdateOverrides.delete(key); + resourceTagOverrides.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); + instanceUpdateStates.delete(instanceArn); + resourceTagOverrides.delete(instanceArn); + instanceDeletionMarkers.put(callerAccountId, true); + } + + 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); + 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); + } + } + 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); + } + } + 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())) { + 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); + } + } + applicationSessionConfigurations.delete(applicationArn); + } + + public boolean getApplicationAssignmentConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + 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); + 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 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 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); + 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); + 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 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 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(region).equals(applicationProviderArn)) { + throw notFound("Application provider not found: " + applicationProviderArn); + } + return applicationProviderArn; + } + + 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(region)), value -> value, + requested, text(request, "NextToken"), 50, 100, "ValidationException"); + } + + 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 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); + 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 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 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); + 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); + 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 String getApplicationSessionConfiguration(JsonNode request) { + String applicationArn = validateApplicationArn(required(request, "ApplicationArn")); + getApplication(applicationArn); + 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); + 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 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); + 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); + 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); + 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) { + String instanceArn = required(request, "InstanceArn"); + SsoInstance instance = requireInstance(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."); + } + String customProviderArn = globalArn("sso", region, "aws", "applicationProvider/custom"); + if (!customProviderArn.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 instanceId = instanceArn.substring(instanceArn.lastIndexOf('/') + 1); + String ownerAccountId = instance.ownerAccountId() == null ? callerAccountId : instance.ownerAccountId(); + 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); + applications.put(applicationArn, application); + if (tokenKey != null) { + applicationClientTokens.put(tokenKey, applicationArn); + } + 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")); + 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 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")); + 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 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, @@ -82,19 +1492,22 @@ 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")); + 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); } 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<>(), null); + new LinkedHashMap<>(), new LinkedHashMap<>(), null, null, tags); permissionSets.put(arn, permissionSet); return permissionSet; } @@ -105,14 +1518,39 @@ 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); + resourceTagOverrides.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(); 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(), current.permissionsBoundary(), + new LinkedHashMap<>(current.tags())); permissionSets.put(updated.arn(), updated); + markPermissionSetProvisioningStale(updated.arn()); return updated; } @@ -122,11 +1560,75 @@ 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); + markPermissionSetProvisioningStale(arn); + } + + 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); + 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 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) { @@ -136,19 +1638,90 @@ 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) { 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, current.permissionsBoundary(), + new LinkedHashMap<>(current.tags()))); + markPermissionSetProvisioningStale(arn); } 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, current.permissionsBoundary(), + new LinkedHashMap<>(current.tags()))); + 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 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, + new LinkedHashMap<>(current.tags()))); + markPermissionSetProvisioningStale(permissionSetArn); + } + + 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, + new LinkedHashMap<>(current.tags()))); + markPermissionSetProvisioningStale(permissionSetArn); } public PaginatedResult listAssignments(JsonNode request) { @@ -173,14 +1746,186 @@ 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 -> { + 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; + } + try { + Set groupIds = groupsByIdentityStore.computeIfAbsent(storeId, + id -> identityStoreService.groupIdsForUser(id, userId)); + return groupIds.contains(assignment.principalId()); + } catch (AwsException e) { + invalidIdentityStores.add(storeId); + return false; + } + }) + .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); + 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 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, "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"); + } + + 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())) { + permissionSetProvisionings.put(permissionSetArn + "::" + provisioning.accountId(), + new PermissionSetProvisioning(permissionSetArn, provisioning.accountId(), + "LATEST_PERMISSION_SET_NOT_PROVISIONED")); + } + } + } + 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)) { @@ -192,13 +1937,85 @@ 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); + AssignmentOperation operation = new AssignmentOperation(requestId, "SUCCEEDED", System.currentTimeMillis(), + account, permission, principal, principalType, null); assignmentOperations.put(requestId, operation); 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 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 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}")) { @@ -207,6 +2024,60 @@ 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"); + } + + 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()) { @@ -231,6 +2102,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+=,.@-]+."); @@ -286,6 +2183,292 @@ private static void validateInlinePolicy(String policy) { } } + 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() + .map(application -> applicationUpdateOverrides.get(application.applicationArn()).orElse(application)) + .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); + SsoApplication base = applications.get(applicationArn) + .orElseThrow(() -> notFound("Application not found: " + applicationArn)); + return applicationUpdateOverrides.get(applicationArn).orElse(base); + } + + 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) { + 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 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."); + } + 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; + } + 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 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; + } + 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() + || key.regionMatches(true, 0, "aws:", 0, 4)) { + 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(); + } + + 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(); + 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; + } + + static String applicationAuthenticationMethodKey(String applicationArn, String authenticationMethodType) { + 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."); + } + return value; + } + private static String validateAccountId(String value) { if (value == null || !value.matches("\\d{12}")) { throw validation("AWS account identifiers must contain 12 digits."); @@ -304,15 +2487,39 @@ private static String valueOr(JsonNode request, String field, String fallback) { String value = text(request, field); return value == null || value.isBlank() ? fallback : value; } + 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) { + 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); } 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) { + 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); } + 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 @@ -320,6 +2527,29 @@ public void clear() { permissionSets.clear(); assignments.clear(); assignmentOperations.clear(); + assignmentDeletionOperations.clear(); + permissionSetProvisionings.clear(); + permissionSetProvisioningOperations.clear(); + regions.clear(); + applications.clear(); + applicationUpdateOverrides.clear(); + applicationClientTokens.clear(); + applicationAssignments.clear(); + applicationAccessScopes.clear(); + applicationAssignmentConfigurations.clear(); + applicationAuthenticationMethods.clear(); + applicationGrants.clear(); + applicationSessionConfigurations.clear(); + resourceTagOverrides.clear(); + instances.clear(); + instanceUpdateStates.clear(); + instanceClientTokens.clear(); + instanceDeletionMarkers.clear(); + accessControlAttributeConfigurations.clear(); + trustedTokenIssuers.clear(); + trustedTokenIssuerUpdateOverrides.clear(); + trustedTokenIssuerClientTokens.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/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/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/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/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/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/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/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/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/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/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/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/PermissionSet.java b/src/main/java/io/github/hectorvent/floci/services/ssoadmin/model/PermissionSet.java index 5f2fe242ae..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 @@ -6,4 +6,14 @@ @RegisterForReflection public record PermissionSet(String arn, String name, String description, String sessionDuration, - Map managedPolicies, String inlinePolicy) {} + Map managedPolicies, + Map customerManagedPolicies, + String inlinePolicy, + 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/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/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/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/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/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/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/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..d5059bb265 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcController.java @@ -0,0 +1,414 @@ +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.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; +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; +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; +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; + private final String localPrincipalId; + + @Inject + public SsoOidcController(SsoOidcService service, SsoAdminService ssoAdminService, + 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 + @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"); + } + } + + @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"); + } + } + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_JSON) + public Response createToken(String body, + @QueryParam("aws_iam") String awsIam, + @HeaderParam("Authorization") String authorizationHeader) { + try { + 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) { + return oidcError(500, "server_error", "The request could not be processed"); + } + } + + 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 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()) { + throw new SsoOidcException("invalid_request", field + " is required", 400); + } + return value.textValue(); + } + + @GET + @Path("/device") + public Response authorizeDevice(@QueryParam("user_code") String userCode, + @QueryParam("principal_id") String principalId) { + try { + DeviceAuthorization authorization = service.authorizeDevice(userCode, resolveLocalPrincipal(principalId)); + 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, + @QueryParam("principal_id") String principalId) { + 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; + 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.createAuthorizationCodeForPrincipal( + clientId, redirectUri, codeChallenge, redirects, resolveLocalPrincipal(principalId)); + } else { + var client = service.requireClient(clientId); + authorization = service.createAuthorizationCodeForPrincipal( + clientId, redirectUri, codeChallenge, client.redirectUris(), resolveLocalPrincipal(principalId)); + } + 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() + .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..379c2697e6 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcService.java @@ -0,0 +1,539 @@ +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.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; +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 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", + "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 StorageBackend deviceAuthorizations; + private final StorageBackend authorizationCodes; + private final StorageBackend tokenSessions; + 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>() {}), + 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); + } + + 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 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, + 0L, + null); + deviceAuthorizations.put(deviceCode, authorization); + return authorization; + } + + public synchronized DeviceAuthorization authorizeDevice(String userCode, String principalId) { + 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(), principalId); + 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); + } + 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, null); + } + + private AuthorizationCode createAuthorizationCode( + 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); + } + 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, principalId); + 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, + authorization.principalId())); + throw new SsoOidcException("authorization_pending", "Device authorization is pending", 400); + } + deviceAuthorizations.delete(deviceCode); + return issueToken(client, authorization.principalId()); + } + + 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, authorizationCode.principalId()); + } + + 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) { + revokeTokenPair(prior); + throw new SsoOidcException("expired_token", "Refresh token has expired", 400); + } + rotateRefreshToken(prior); + return issueToken(client, prior.principalId()); + } + + 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) { + 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, null); + } + 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, authorizationCode.principalId()); + } + + 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); + } + rotateRefreshToken(prior); + return issueToken(applicationArn, grantedScopes, true, prior.principalId()); + } + + 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), subject.principalId()); + } + + 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) { + 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 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); + } + + 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, + principalId); + tokenSessions.put("access:" + accessToken, session); + if (refreshToken != null) { + 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); + } + return clients.get(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"; + } + + public String tokenEndpoint() { + return baseUrl + "/token"; + } + + @Override + public void clear() { + clients.clear(); + deviceAuthorizations.clear(); + authorizationCodes.clear(); + tokenSessions.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; + } + + 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 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"; + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } +} 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..453e9545a0 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/AuthorizationCode.java @@ -0,0 +1,13 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record AuthorizationCode( + String code, + String clientId, + String redirectUri, + String codeChallenge, + 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 new file mode 100644 index 0000000000..b9b9286f80 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/DeviceAuthorization.java @@ -0,0 +1,16 @@ +package io.github.hectorvent.floci.services.ssooidc.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record DeviceAuthorization( + String deviceCode, + String userCode, + String clientId, + String startUrl, + long expiresAtEpochSeconds, + int intervalSeconds, + boolean authorized, + long lastPollAtEpochMillis, + String principalId +) {} 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..1d6177a9d9 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/RegisteredClient.java @@ -0,0 +1,26 @@ +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, + 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/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..634ae39c13 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssooidc/model/TokenSession.java @@ -0,0 +1,20 @@ +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, + String clientId, + List scopes, + long accessTokenExpiresAtEpochSeconds, + 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..f23cff1908 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalController.java @@ -0,0 +1,99 @@ +package io.github.hectorvent.floci.services.ssoportal; + +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; +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; +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; + } + + @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( + @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( + @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( + @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..40322cbf2f --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalService.java @@ -0,0 +1,145 @@ +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.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; +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 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, + IamService iamService) { + this.oidcService = oidcService; + this.ssoAdminService = ssoAdminService; + this.organizationsService = organizationsService; + this.iamService = iamService; + } + + 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 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 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 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."); + } + 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); + } + + 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/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/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/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/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/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/identitystore/ScimIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java new file mode 100644 index 0000000000..35ad68ff66 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/identitystore/ScimIntegrationTest.java @@ -0,0 +1,910 @@ +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; +import static org.hamcrest.Matchers.nullValue; + +@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 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() + .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 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 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() + .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() + .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() + .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() + .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")); + } + + @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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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")); + } +} 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..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 @@ -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\":\"" @@ -38,13 +38,76 @@ 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 + "\"}") .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 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.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("")); + } + + @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("")); + 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 void invalidAssignmentRequestIdReturnsResourceNotFound() { String instanceArn = json("SWBExternalService.ListInstances", "{}") 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..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 @@ -51,11 +51,1397 @@ void listInstances_isStableAcrossCalls() { } @Test - void unknownAction_returnsUnknownOperationException() { + 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"; + 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\"}"; + 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.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) + .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) + .header("X-Amz-Target", "SWBExternalService.AddRegion") + .body(request) + .when() + .post("/") + .then() + .statusCode(400) + .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"; + 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")); + + 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")); + + 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 + 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 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 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")); + + 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 + 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\"," + + "\"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())); + + 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")); + + 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 + 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() + .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 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 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() + .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())); + + 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 + 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() + .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\"," + + "\"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 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")); + + 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 + 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\"," + + "\"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 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")); + + 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 + 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")); + + 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 + 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\"," + + "\"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\"," + + "\"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)); + + 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")); + + 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) + .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("IntegrationIssuerUpdated")) + .body("TrustedTokenIssuers.find { it.TrustedTokenIssuerArn == '" + arn + "' }.TrustedTokenIssuerType", equalTo("OIDC_JWT")); + } + + @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\"," + + "\"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())); + + 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 + 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"; + 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.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) + .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 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() { + 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 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 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"); + + 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\"}") + .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}")) + .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")); + + 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 + 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 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(); + 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); + + String deletionRequestId = 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)) + .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")); + + 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 + void unknownAction_returnsUnknownOperationException() { + given() + .contentType("application/x-amz-json-1.1") + .header("Authorization", AUTH_HEADER) + .header("X-Amz-Target", "SWBExternalService.NotARealOperation") .body("{}") .when() .post("/") @@ -65,7 +1451,7 @@ void unknownAction_returnsUnknownOperationException() { } @Test - void listInstances_ownerAccountIdTracksTheCaller() { + void listInstancesReturnsEmptyForAnAccountWithoutAVisibleInstance() { given() .contentType("application/x-amz-json-1.1") .header("Authorization", @@ -76,7 +1462,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 9af1fe7b15..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 @@ -6,13 +6,32 @@ 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.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.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; +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.identitystore.IdentityStoreService; +import io.github.hectorvent.floci.services.organizations.OrganizationsService; 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; 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; @@ -22,13 +41,1373 @@ class SsoAdminServiceTest { private final ObjectMapper mapper = new ObjectMapper(); private SsoAdminService service; + private IdentityStoreService identityStoreService; + private OrganizationsService organizationsService; + private InMemoryStorage applicationAccessScopes; + private InMemoryStorage applicationAuthenticationMethods; + private InMemoryStorage applicationGrants; @BeforeEach void setUp() { + identityStoreService = org.mockito.Mockito.mock(IdentityStoreService.class); + organizationsService = org.mockito.Mockito.mock(OrganizationsService.class); + applicationAccessScopes = new InMemoryStorage<>(); + applicationAuthenticationMethods = new InMemoryStorage<>(); + applicationGrants = new InMemoryStorage<>(); service = new SsoAdminService( new InMemoryStorage(), new InMemoryStorage(), - new InMemoryStorage()); + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + applicationAccessScopes, + new InMemoryStorage(), + applicationAuthenticationMethods, + applicationGrants, + new InMemoryStorage(), + new InMemoryStorage>(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + identityStoreService, + 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 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", + () -> 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"); + 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); + 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(); + 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())); + ObjectNode describeRequest = mapper.createObjectNode().put("InstanceArn", service.getInstanceArn()); + assertEquals(created, service.describeInstanceAccessControlAttributeConfiguration(describeRequest)); + 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 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(); + 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(); + 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 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(); + 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 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(); + 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}")); + 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()); + + 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(); + 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 + 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 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(); + 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 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 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"); + 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()); + assertEquals(created, service.getApplicationAccessScope(request)); + + 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 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"); + 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 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 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", "***"); + assertError("ValidationException", () -> service.listApplicationAuthenticationMethods(badToken)); + } + + @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 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)); + assertTrue(service.iamActorPolicyAllows(application.applicationArn(), "999999999999")); + } + @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 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"); + 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"); + 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", "***"); + assertError("ValidationException", () -> service.listApplicationGrants(badToken)); + } + + @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"); + 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"); + 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"); + 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 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 describeApplicationProviderReturnsCustomOauthProviderAndValidatesArn() { + ObjectNode request = mapper.createObjectNode(); + request.put("ApplicationProviderArn", "arn:aws:sso::aws:applicationProvider/custom"); + 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, "us-east-1")); + + ObjectNode malformed = mapper.createObjectNode(); + malformed.put("ApplicationProviderArn", "not-an-arn"); + assertError("ValidationException", () -> service.describeApplicationProvider(malformed, "us-east-1")); + } + + @Test + void listApplicationProvidersReturnsCustomProviderAndValidatesPagination() { + ObjectNode request = mapper.createObjectNode(); + 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, "us-east-1")); + } + + @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 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"); + 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"); + 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 + 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 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(); + 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 + 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()); + + RegionMetadata described = service.describeRegion(request); + assertEquals("us-west-2", described.regionName()); + 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"); + 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(); + 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 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")) { + 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 + 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 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 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)); + + 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"); + ObjectNode request = mapper.createObjectNode(); + request.put("InstanceArn", service.getInstanceArn()); + request.put("PermissionSetArn", permissionSet.arn()); + request.putObject("CustomerManagedPolicyReference").put("Name", "PlatformPolicy"); + + 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()); + + 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)); + + 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 + 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 @@ -78,12 +1457,214 @@ 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"); + 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 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); + 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); + 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 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)); + + 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 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 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"); 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)); @@ -93,6 +1674,83 @@ 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())); + + 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)); + } + + @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 putPermissionsBoundarySupportsAwsManagedAndCustomerManagedPolicies() { + PermissionSet permissionSet = createPermissionSet("BoundaryAdmins"); + assertError("ResourceNotFoundException", + () -> service.getPermissionsBoundary(service.getInstanceArn(), permissionSet.arn())); + + 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.getPermissionsBoundary(service.getInstanceArn(), permissionSet.arn()).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()); + 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") + .putObject("CustomerManagedPolicyReference") + .put("Name", "BoundaryPolicy"); + assertError("ValidationException", () -> service.putPermissionsBoundary(invalid)); + } + @Test void clearRemovesPersistedServiceState() { PermissionSet permissionSet = createPermissionSet("ResetAdmins"); @@ -107,6 +1765,65 @@ 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(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage>(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + new InMemoryStorage(), + identityStoreService, + 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()); + 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()); 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..8d6472d3cd --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcIntegrationTest.java @@ -0,0 +1,219 @@ +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; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.matchesPattern; + +@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() { + 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 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 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(); + + String userCode = authorization.path("userCode"); + given() + .queryParam("user_code", 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() + .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 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() + .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..ef42f38414 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssooidc/SsoOidcServiceTest.java @@ -0,0 +1,216 @@ +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.assertNull; +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<>(), new InMemoryStorage<>(), new InMemoryStorage<>(), + 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 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 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(), "11111111-1111-1111-1111-111111111111"); + 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)); + } + + @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()); + 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"); + 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") + .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(); + 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/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..1a72e8520d --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/ssoportal/SsoPortalIntegrationTest.java @@ -0,0 +1,177 @@ +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() + .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() + .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) + .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")); + + 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) { + 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(); + } +} 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()); + } +} 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: 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