From 6ed7f9742d233aa59862070fac2093d04dde0642 Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Thu, 8 Jan 2026 13:11:39 -0800 Subject: [PATCH 1/6] support sts access boundary in gcp and aws --- .../com/salesforce/multicloudj/sts/Main.java | 26 ++- .../multicloudj/sts/aws/AwsSts.java | 152 ++++++++++++-- .../multicloudj/sts/aws/AwsStsTest.java | 51 +++++ .../resources/mappings/post-oi7cu8zgp5.json | 4 +- .../sts/model/AssumedRoleRequest.java | 23 +- .../sts/model/CredentialScope.java | 96 +++++++++ .../multicloudj/sts/client/AbstractStsIT.java | 4 +- .../sts/model/AssumedRoleRequestTest.java | 21 ++ .../multicloudj/sts/gcp/GcpSts.java | 197 +++++++++++++++--- .../multicloudj/sts/gcp/GcpStsIT.java | 40 ++-- .../multicloudj/sts/gcp/GcpStsTest.java | 134 +++++++++--- 11 files changed, 631 insertions(+), 117 deletions(-) create mode 100644 sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/CredentialScope.java diff --git a/examples/src/main/java/com/salesforce/multicloudj/sts/Main.java b/examples/src/main/java/com/salesforce/multicloudj/sts/Main.java index 8dcfa9339..41890c767 100644 --- a/examples/src/main/java/com/salesforce/multicloudj/sts/Main.java +++ b/examples/src/main/java/com/salesforce/multicloudj/sts/Main.java @@ -8,6 +8,7 @@ import com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; import com.salesforce.multicloudj.sts.model.CallerIdentity; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.CredentialsOverrider; import com.salesforce.multicloudj.sts.model.CredentialsType; import com.salesforce.multicloudj.sts.model.GetCallerIdentityRequest; @@ -20,10 +21,9 @@ import static com.salesforce.multicloudj.sts.curl.requestToCurl; - public class Main { - static String provider = "aws"; + static String provider = "gcp"; public static void main(String[] args) { assumeRole(); @@ -35,9 +35,29 @@ public static void main(String[] args) { public static void assumeRole() { StsClient client = StsClient.builder(provider).withRegion("us-west-2").build(); + + // Create a cloud-agnostic credential scope with condition + CredentialScope.AvailabilityCondition condition = CredentialScope.AvailabilityCondition.builder() + .resourcePrefix("storage://my-bucket/documents/") + .title("Limit to documents folder") + .description("Only allow access to objects in the documents folder") + .build(); + + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://my-bucket/*") + .availablePermission("storage:GetObject") + .availablePermission("storage:PutObject") + .availabilityCondition(condition) + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() - .withRole("arn:aws:iam:::role/") + .withRole("chameleon@substrate-sdk-gcp-poc1.iam.gserviceaccount.com") .withSessionName("my-session") + .withCredentialScope(credentialScope) .build(); StsCredentials stsCredentials = client.getAssumeRoleCredentials(request); diff --git a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java index c17a8a059..6e24cd5ab 100644 --- a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java +++ b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java @@ -9,6 +9,7 @@ import com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; import com.salesforce.multicloudj.sts.model.CallerIdentity; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.GetAccessTokenRequest; import com.salesforce.multicloudj.sts.model.StsCredentials; import software.amazon.awssdk.awscore.exception.AwsServiceException; @@ -25,12 +26,14 @@ import software.amazon.awssdk.services.sts.model.GetSessionTokenRequest; import software.amazon.awssdk.services.sts.model.GetSessionTokenResponse; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; -@SuppressWarnings("rawtypes") @AutoService(AbstractSts.class) public class AwsSts extends AbstractSts { @@ -62,12 +65,18 @@ public Builder builder() { @Override protected StsCredentials getSTSCredentialsWithAssumeRole(AssumedRoleRequest request) { - AssumeRoleRequest roleRequest = AssumeRoleRequest.builder() + AssumeRoleRequest.Builder roleRequestBuilder = AssumeRoleRequest.builder() .roleArn(request.getRole()) .roleSessionName(request.getSessionName() != null ? request.getSessionName() : "multicloudj-" + System.currentTimeMillis()) - .durationSeconds(request.getExpiration() != 0 ? request.getExpiration() : null) - .build(); - AssumeRoleResponse response = stsClient.assumeRole(roleRequest); + .durationSeconds(request.getExpiration() != 0 ? request.getExpiration() : null); + + // If credential scope is provided, convert to AWS IAM policy JSON + if (request.getCredentialScope() != null) { + String policyJson = convertToAwsPolicy(request.getCredentialScope()); + roleRequestBuilder.policy(policyJson); + } + + AssumeRoleResponse response = stsClient.assumeRole(roleRequestBuilder.build()); Credentials credentials = response.credentials(); return new StsCredentials( @@ -76,6 +85,132 @@ protected StsCredentials getSTSCredentialsWithAssumeRole(AssumedRoleRequest requ credentials.sessionToken()); } + /** + * Converts cloud-agnostic CredentialScope to AWS IAM Policy JSON. + */ + private String convertToAwsPolicy(CredentialScope credentialScope) { + List> statements = new ArrayList<>(); + + for (CredentialScope.ScopeRule rule : credentialScope.getRules()) { + Map statement = new HashMap<>(); + statement.put("Effect", "Allow"); + + // Convert permissions (format: "storage:GetObject" -> "s3:GetObject") + List actions = rule.getAvailablePermissions().stream() + .map(this::convertPermissionToAction) + .collect(Collectors.toList()); + statement.put("Action", actions); + + // Convert resource (format: "storage://my-bucket" -> "arn:aws:s3:::my-bucket/*") + String resource = convertResourceToArn(rule.getAvailableResource()); + statement.put("Resource", resource); + + // Add condition if present + if (rule.getAvailabilityCondition() != null) { + Map condition = convertConditionToAwsCondition( + rule.getAvailabilityCondition()); + if (!condition.isEmpty()) { + statement.put("Condition", condition); + } + } + + statements.add(statement); + } + + Map policy = new HashMap<>(); + policy.put("Version", "2012-10-17"); + policy.put("Statement", statements); + + // Convert to JSON string + return toJsonString(policy); + } + + /** + * Converts cloud-agnostic permission to AWS Action. + * Maps MultiCloudJ storage actions to AWS S3 actions. + * Example: "storage:GetObject" -> "s3:GetObject" + */ + private String convertPermissionToAction(String permission) { + String action = permission.substring("storage:".length()); + return "s3:" + action; + } + + /** + * Converts cloud-agnostic resource to AWS ARN. + * Maps MultiCloudJ storage URIs to AWS S3 ARNs. + * Example: "storage://my-bucket" -> "arn:aws:s3:::my-bucket/*" + */ + private String convertResourceToArn(String resource) { + String bucketName = resource.substring("storage://".length()); + // AWS requires /* suffix for bucket-level access + return "arn:aws:s3:::" + bucketName + "/*"; + } + + /** + * Converts cloud-agnostic availability condition to AWS IAM condition. + * Converts resourcePrefix to AWS IAM Condition with StringLike and s3:prefix. + * Example: "storage://my-bucket/documents/" -> {"StringLike": {"s3:prefix": "documents/"}} + */ + private Map convertConditionToAwsCondition( + CredentialScope.AvailabilityCondition condition) { + Map awsCondition = new HashMap<>(); + + // Convert cloud-agnostic resourcePrefix to AWS IAM condition + if (condition.getResourcePrefix() != null && !condition.getResourcePrefix().isEmpty()) { + String resourcePrefix = condition.getResourcePrefix(); + if (resourcePrefix.startsWith("storage://")) { + String path = resourcePrefix.substring("storage://".length()); + // Extract path prefix after bucket name + if (path.contains("/")) { + String pathPrefix = path.substring(path.indexOf("/") + 1); + if (!pathPrefix.isEmpty()) { + Map stringLike = new HashMap<>(); + stringLike.put("s3:prefix", pathPrefix); + awsCondition.put("StringLike", stringLike); + } + } + } + } + + return awsCondition; + } + + /** + * Converts Map to JSON string. + */ + private String toJsonString(Map map) { + // Simple JSON serialization - in production, use a proper JSON library + StringBuilder json = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) json.append(","); + first = false; + json.append("\"").append(entry.getKey()).append("\":"); + json.append(toJsonValue(entry.getValue())); + } + json.append("}"); + return json.toString(); + } + + @SuppressWarnings("unchecked") + private String toJsonValue(Object value) { + if (value == null) { + return "null"; + } else if (value instanceof String) { + return "\"" + value + "\""; + } else if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } else if (value instanceof List) { + List list = (List) value; + return "[" + list.stream() + .map(this::toJsonValue) + .collect(Collectors.joining(",")) + "]"; + } else if (value instanceof Map) { + return toJsonString((Map) value); + } + return "\"" + value + "\""; + } + @Override protected CallerIdentity getCallerIdentityFromProvider(com.salesforce.multicloudj.sts.model.GetCallerIdentityRequest request) { GetCallerIdentityRequest callerIdentityRequest = GetCallerIdentityRequest.builder().build(); @@ -152,13 +287,6 @@ public Builder self() { return this; } - public Builder setParam(Map params) { - if (!Objects.equals(params.get("customPro"), "")) { - param = params.get("customPro"); - } - return this; - } - @Override public AwsSts build() { this.param = region; diff --git a/sts/sts-aws/src/test/java/com/salesforce/multicloudj/sts/aws/AwsStsTest.java b/sts/sts-aws/src/test/java/com/salesforce/multicloudj/sts/aws/AwsStsTest.java index e9796c4fb..6dd4e6e2a 100644 --- a/sts/sts-aws/src/test/java/com/salesforce/multicloudj/sts/aws/AwsStsTest.java +++ b/sts/sts-aws/src/test/java/com/salesforce/multicloudj/sts/aws/AwsStsTest.java @@ -1,8 +1,11 @@ package com.salesforce.multicloudj.sts.aws; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; import com.salesforce.multicloudj.sts.model.CallerIdentity; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.GetAccessTokenRequest; import com.salesforce.multicloudj.sts.model.StsCredentials; import org.junit.jupiter.api.Assertions; @@ -120,4 +123,52 @@ public void TestAssumeRoleWithWebIdentityWithoutSessionName() { Assertions.assertEquals("testSecret", credentials.getAccessKeySecret()); Assertions.assertEquals("testToken", credentials.getSecurityToken()); } + + private void assertJsonEquals(String expected, String actual) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedNode = mapper.readTree(expected); + JsonNode actualNode = mapper.readTree(actual); + Assertions.assertEquals(expectedNode, actualNode); + } + + @Test + public void TestAssumeRoleWithCredentialScope() throws Exception { + AwsSts sts = new AwsSts().builder().build(mockStsClient); + + // Create cloud-agnostic CredentialScope with availability condition + CredentialScope.AvailabilityCondition condition = CredentialScope.AvailabilityCondition.builder() + .resourcePrefix("storage://my-bucket/documents/") + .title("Limit to documents folder") + .description("Only allow access to objects in the documents folder") + .build(); + + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://my-bucket") + .availablePermission("storage:GetObject") + .availablePermission("storage:PutObject") + .availabilityCondition(condition) + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withRole("arn:aws:iam::123456789012:role/test-role") + .withSessionName("testSession") + .withCredentialScope(credentialScope) + .build(); + + StsCredentials credentials = sts.assumeRole(request); + Assertions.assertNotNull(credentials); + + // Verify that policy was set with correct conversion including condition + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(AssumeRoleRequest.class); + Mockito.verify(mockStsClient, Mockito.atLeastOnce()).assumeRole(captor.capture()); + AssumeRoleRequest capturedRequest = captor.getValue(); + + // Verify the complete policy structure with condition (parse JSON to ignore key ordering) + String expectedPolicy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":\"arn:aws:s3:::my-bucket/*\",\"Condition\":{\"StringLike\":{\"s3:prefix\":\"documents/\"}}}]}"; + assertJsonEquals(expectedPolicy, capturedRequest.policy()); + } } diff --git a/sts/sts-aws/src/test/resources/mappings/post-oi7cu8zgp5.json b/sts/sts-aws/src/test/resources/mappings/post-oi7cu8zgp5.json index a1fce9c11..f1216df06 100644 --- a/sts/sts-aws/src/test/resources/mappings/post-oi7cu8zgp5.json +++ b/sts/sts-aws/src/test/resources/mappings/post-oi7cu8zgp5.json @@ -5,13 +5,13 @@ "url" : "/", "method" : "POST", "bodyPatterns" : [ { - "equalTo" : "Action=AssumeRole&Version=2011-06-15&RoleArn=arn%3Aaws%3Aiam%3A%3A654654370895%3Arole%2Fchameleon-jcloud-test&RoleSessionName=any-session", + "equalTo" : "Action=AssumeRole&Version=2011-06-15&RoleArn=arn%3Aaws%3Aiam%3A%3A654654370895%3Arole%2Fchameleon-jcloud-test&RoleSessionName=any-session&DurationSeconds=3600", "caseInsensitive" : false } ] }, "response" : { "status" : 200, - "body" : "\n \n \n AROAZQ3DQ6RHVFNX2KMJM:any-session\n arn:aws:sts::654654370895:assumed-role/chameleon-jcloud-test/any-session\n \n \n ASIAZQ3DQ6RHUHXN5WQ4\n gY8iT2TtBR/fuNS19U9L982ENLqbnIb+yp+XYLJm\n IQoJb3JpZ2luX2VjENv//////////wEaCXVzLXdlc3QtMiJIMEYCIQDxjYcXDRVfgKhH3ZYt026cz24g2w04d3FC7ORAyhPreAIhAIvfMmMoznPyul2Yg3kBtV31M3tvDKT97R2+3WOjTpC+KqECCPT//////////wEQABoMNjU0NjU0MzcwODk1IgzPfj05k2RjXQiU6+Mq9QFu9RdGQO/R4MRlRXmYhViCAv7Vg16jRnYAfHSVuKt+SctywLqqyqrkRs0kyTlCp0BZ4o0d9FR3ODzD9dOiMWZ+haB8iwJ0hrbHQPz9mmipTici8RFyc4uwfPWEkhc3Ra4U/TzfDs8N98Fry7xZjZmZ+1a378YpNLoWeSnFjXPMh2Ivkp01zi+k721nHf4nfpFFjgFNwsnEopE1Bc8G8W3ak0gqMZJQ6Oeo18HloFhbNpJBfIc7gjacpfPEvIWEKMOTtBGBKO0Fi+W/T2dPIfdrZhbbYdnOxEPEAEM9O5KFJKqwYAtDpoFjAb9xpOCFk47bkusY6zCs++e2BjqcAc1JPov5+mjvw+8PQ2grLuMXoxda+ZaP3xIJQ3xg1nr0KO8pJkHe46Tj92b1iy3lWpy2bZVAI9qFGBS2ZY53Oy5EXZb56o/k9quLAQjy/PcGvyB9m1KJln6LDhhKf+KCajpJWWG9iZWQIosJPXU64A6XxMju0JXC7S1nXNH46AuF2JwPAor0CxK7tHimuHjYN7Cxl7Gv1s7K/2oqWw==\n 2024-09-05T19:51:24Z\n \n \n \n 5ad47c30-460b-4fe5-a15b-f3a4cef730f3\n \n\n", + "body" : "\n \n \n AROAZQ3DQ6RHVFNX2KMJM:any-session\n arn:aws:sts::654654370895:assumed-role/chameleon-jcloud-test/any-session\n \n \n keyid\n secretkey\n secrettoken\n 2024-09-05T19:51:24Z\n \n \n \n 5ad47c30-460b-4fe5-a15b-f3a4cef730f3\n \n\n", "headers" : { "x-amzn-RequestId" : "5ad47c30-460b-4fe5-a15b-f3a4cef730f3", "Date" : "Thu, 05 Sep 2024 18:51:24 GMT", diff --git a/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequest.java b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequest.java index 79f2a1bfd..8bb14bc67 100644 --- a/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequest.java +++ b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequest.java @@ -1,27 +1,20 @@ package com.salesforce.multicloudj.sts.model; +import lombok.Getter; + +@Getter public class AssumedRoleRequest { private final String role; private final String sessionName; private final int expiration; + private final CredentialScope credentialScope; private AssumedRoleRequest(Builder b) { this.role = b.role; this.sessionName = b.sessionName; this.expiration = b.expiration; - } - - public String getRole() { - return role; - } - - public String getSessionName() { - return sessionName; - } - - public int getExpiration() { - return expiration; + this.credentialScope = b.credentialScope; } public static Builder newBuilder() { @@ -32,6 +25,7 @@ public static class Builder { private String role; private String sessionName; private int expiration; + private CredentialScope credentialScope; public Builder() { } @@ -52,6 +46,11 @@ public Builder withExpiration(int expiration) { return this; } + public Builder withCredentialScope(CredentialScope credentialScope) { + this.credentialScope = credentialScope; + return this; + } + public AssumedRoleRequest build(){ return new AssumedRoleRequest(this); } diff --git a/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/CredentialScope.java b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/CredentialScope.java new file mode 100644 index 000000000..47089d079 --- /dev/null +++ b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/model/CredentialScope.java @@ -0,0 +1,96 @@ +package com.salesforce.multicloudj.sts.model; + +import lombok.Builder; +import lombok.Getter; +import lombok.Singular; + +import java.util.List; + +/** + * Cloud-agnostic representation of credential scope restrictions for downs-coped credentials based on + * access boundary or policies. + * This defines restrictions on what resources can be accessed and what permissions are available. + * Maps to AccessBoundary in GCP and Policy in AWS. + * + *

Usage example with cloud-agnostic format: + *

+ * CredentialScope scope = CredentialScope.builder()
+ *     .rule(CredentialScope.ScopeRule.builder()
+ *         .availableResource("storage://my-bucket")
+ *         .availablePermission("storage:GetObject")
+ *         .availablePermission("storage:PutObject")
+ *         .availabilityCondition(CredentialScope.AvailabilityCondition.builder()
+ *             .resourcePrefix("storage://my-bucket/prefix/")
+ *             .title("Limit to documents folder")
+ *             .description("Only allow access to objects in the documents folder")
+ *             .build())
+ *         .build())
+ *     .build();
+ * 
+ * + *

Use cloud-agnostic action formats: + *

    + *
  • storage:GetObject - Read objects from storage
  • + *
  • storage:PutObject - Write objects to storage
  • + *
  • storage:DeleteObject - Delete objects from storage
  • + *
  • storage:ListBucket - List bucket contents
  • + *
+ * + *

Use cloud-agnostic resource formats: + *

    + *
  • storage://bucket-name - Specifies the bucket resource (use resourcePrefix in AvailabilityCondition to restrict to specific prefixes)
  • + *
+ */ +@Getter +@Builder +public class CredentialScope { + + @Singular + private final List rules; + + /** + * Represents a single rule in a credential scope. + */ + @Getter + @Builder + public static class ScopeRule { + private final String availableResource; + @Singular + private final List availablePermissions; + private final AvailabilityCondition availabilityCondition; + } + + /** + * Represents a condition that must be met for a rule to apply. + * Uses cloud-agnostic structured conditions. + * + *

Usage example: + *

+     * AvailabilityCondition.builder()
+     *     .resourcePrefix("storage://my-bucket/documents/")
+     *     .title("Limit to documents folder")
+     *     .description("Only allow access to documents")
+     *     .build()
+     * 
+ */ + @Getter + @Builder + public static class AvailabilityCondition { + /** + * Cloud-agnostic resource prefix constraint. + * Example: "storage://my-bucket/documents/" restricts access to objects under that prefix. + * Automatically converted to provider-specific format (CEL for GCP, IAM Condition for AWS). + */ + private final String resourcePrefix; + + /** + * Optional title for the condition. + */ + private final String title; + + /** + * Optional description of what this condition restricts. + */ + private final String description; + } +} diff --git a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java index dcfe9b432..7884d6a43 100644 --- a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java +++ b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java @@ -112,8 +112,8 @@ public void testGetAccessToken() { public void testAssumeRole() { AbstractSts sts = harness.createStsDriver(false); StsClient stsClient = new StsClient(sts); - AssumedRoleRequest request = AssumedRoleRequest.newBuilder().withRole(harness.getRoleName()).withSessionName("any-session").build(); - StsCredentials credentials = stsClient.getAssumeRoleCredentials(request); + AssumedRoleRequest request = AssumedRoleRequest.newBuilder().withRole(harness.getRoleName()).withExpiration(3600).withSessionName("any-session").build(); + StsCredentials credentials = stsClient. getAssumeRoleCredentials(request); Assertions.assertNotNull(credentials, "Credentials shouldn't be empty"); Assertions.assertNotNull(credentials.getAccessKeySecret()); Assertions.assertNotNull(credentials.getAccessKeyId()); diff --git a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequestTest.java b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequestTest.java index 18213a587..fb7c64b1e 100644 --- a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequestTest.java +++ b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/model/AssumedRoleRequestTest.java @@ -19,5 +19,26 @@ public void TestAssumedRoleRequestBuilderWithDefaultValues() { Assertions.assertNull(request.getRole()); Assertions.assertNull(request.getSessionName()); Assertions.assertEquals(0, request.getExpiration()); + Assertions.assertNull(request.getCredentialScope()); + } + + @Test + public void TestAssumedRoleRequestBuilderWithCredentialScope() { + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://test-bucket/*") + .availablePermission("storage:GetObject") + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withRole("testRole") + .withCredentialScope(credentialScope) + .build(); + Assertions.assertEquals("testRole", request.getRole()); + Assertions.assertNotNull(request.getCredentialScope()); + Assertions.assertEquals(1, request.getCredentialScope().getRules().size()); } } diff --git a/sts/sts-gcp/src/main/java/com/salesforce/multicloudj/sts/gcp/GcpSts.java b/sts/sts-gcp/src/main/java/com/salesforce/multicloudj/sts/gcp/GcpSts.java index 475b03904..1317bde8a 100644 --- a/sts/sts-gcp/src/main/java/com/salesforce/multicloudj/sts/gcp/GcpSts.java +++ b/sts/sts-gcp/src/main/java/com/salesforce/multicloudj/sts/gcp/GcpSts.java @@ -2,15 +2,16 @@ import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.StatusCode; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.AccessToken; import com.google.auth.oauth2.ComputeEngineCredentials; +import com.google.auth.oauth2.CredentialAccessBoundary; +import com.google.auth.oauth2.DownscopedCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenCredentials; import com.google.auth.oauth2.IdTokenProvider; +import com.google.auth.oauth2.ImpersonatedCredentials; import com.google.auto.service.AutoService; -import com.google.cloud.iam.credentials.v1.GenerateAccessTokenRequest; -import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse; -import com.google.cloud.iam.credentials.v1.IamCredentialsClient; -import com.google.protobuf.Duration; import com.salesforce.multicloudj.common.exceptions.DeadlineExceededException; import com.salesforce.multicloudj.common.exceptions.FailedPreconditionException; import com.salesforce.multicloudj.common.exceptions.InvalidArgumentException; @@ -26,6 +27,7 @@ import com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; import com.salesforce.multicloudj.sts.model.CallerIdentity; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.GetAccessTokenRequest; import com.salesforce.multicloudj.sts.model.GetCallerIdentityRequest; import com.salesforce.multicloudj.sts.model.StsCredentials; @@ -39,47 +41,180 @@ @AutoService(AbstractSts.class) public class GcpSts extends AbstractSts { private final String scope = "https://www.googleapis.com/auth/cloud-platform"; - private IamCredentialsClient stsClient; - /** - * Optionally injected GoogleCredentials (used primarily for testing). If null the - * class falls back to {@code GoogleCredentials.getApplicationDefault()} at runtime. - */ + private GoogleCredentials googleCredentials; + private HttpTransportFactory httpTransportFactory; + public GcpSts(Builder builder) { super(builder); - try { - this.stsClient = IamCredentialsClient.create(); - } catch (IOException e) { - throw new SubstrateSdkException("Could not create IAM client ", e); - } } - public GcpSts(Builder builder, IamCredentialsClient stsClient) { + public GcpSts(Builder builder, GoogleCredentials credentials) { super(builder); - this.stsClient = stsClient; + this.googleCredentials = credentials; + } + + public GcpSts(Builder builder, HttpTransportFactory httpTransportFactory) { + super(builder); + this.httpTransportFactory = httpTransportFactory; } - public GcpSts(Builder builder, IamCredentialsClient stsClient, GoogleCredentials credentials) { + public GcpSts(Builder builder, GoogleCredentials credentials, + HttpTransportFactory httpTransportFactory) { super(builder); - this.stsClient = stsClient; this.googleCredentials = credentials; + this.httpTransportFactory = httpTransportFactory; } public GcpSts() { super(new Builder()); } + /** + * Converts cloud-agnostic CredentialScope to GCP-specific CredentialAccessBoundary. + * Maps cloud-agnostic storage actions and resources to GCP format. + */ + private CredentialAccessBoundary convertToGcpAccessBoundary( + com.salesforce.multicloudj.sts.model.CredentialScope credentialScope) { + CredentialAccessBoundary.Builder gcpBoundaryBuilder = CredentialAccessBoundary.newBuilder(); + + for (CredentialScope.ScopeRule rule : credentialScope.getRules()) { + CredentialAccessBoundary.AccessBoundaryRule.Builder gcpRuleBuilder = + CredentialAccessBoundary.AccessBoundaryRule.newBuilder() + .setAvailableResource(convertToGcpResource(rule.getAvailableResource())); + + // Add permissions - convert cloud-agnostic to GCP format + for (String permission : rule.getAvailablePermissions()) { + gcpRuleBuilder.addAvailablePermission(convertToGcpPermission(permission)); + } + + // Add availability condition if present + if (rule.getAvailabilityCondition() != null) { + CredentialScope.AvailabilityCondition condition = + rule.getAvailabilityCondition(); + CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.Builder gcpConditionBuilder = + CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition.newBuilder(); + + // Convert cloud-agnostic resourcePrefix to GCP CEL format + if (condition.getResourcePrefix() != null) { + String gcpExpression = buildGcpPrefixExpression(condition.getResourcePrefix()); + gcpConditionBuilder.setExpression(gcpExpression); + } + if (condition.getTitle() != null) { + gcpConditionBuilder.setTitle(condition.getTitle()); + } + if (condition.getDescription() != null) { + gcpConditionBuilder.setDescription(condition.getDescription()); + } + + gcpRuleBuilder.setAvailabilityCondition(gcpConditionBuilder.build()); + } + + gcpBoundaryBuilder.addRule(gcpRuleBuilder.build()); + } + + return gcpBoundaryBuilder.build(); + } + + /** + * Converts cloud-agnostic permission to GCP permission format. + * For now, it's limited to storage/gcs service. + * Example: "storage:GetObject" -> "inRole:roles/storage.objectViewer" + */ + private String convertToGcpPermission(String permission) { + String action = permission.substring("storage:".length()); + + // Map common actions to GCP roles + switch (action) { + case "GetObject": + return "inRole:roles/storage.objectViewer"; + case "PutObject": + return "inRole:roles/storage.objectCreator"; + case "DeleteObject": + return "inRole:roles/storage.objectAdmin"; + case "ListBucket": + return "inRole:roles/storage.objectViewer"; + default: + // For unknown actions, default to objectViewer + return "inRole:roles/storage.objectViewer"; + } + } + + /** + * Converts cloud-agnostic resource to GCP resource format. + * For now, it's limited to storage/gcs service. + * Example: "storage://my-bucket" -> "//storage.googleapis.com/projects/_/buckets/my-bucket" + */ + private String convertToGcpResource(String resource) { + String bucketName = resource.substring("storage://".length()); + return "//storage.googleapis.com/projects/_/buckets/" + bucketName; + } + + /** + * Builds GCP CEL expression from cloud-agnostic resource prefix. + * Example: "storage://my-bucket/documents/" -> + * "resource.name.startsWith('projects/_/buckets/my-bucket/objects/documents/')" + */ + private String buildGcpPrefixExpression(String resourcePrefix) { + String path = resourcePrefix.substring("storage://".length()); + // Extract bucket name (before first /) + int slashIdx = path.indexOf('/'); + String bucketName = path.substring(0, slashIdx); + String prefix = path.substring(slashIdx + 1); + String gcpPath = "projects/_/buckets/" + bucketName + "/objects/" + prefix; + return "resource.name.startsWith('" + gcpPath + "')"; + } + @Override protected StsCredentials getSTSCredentialsWithAssumeRole(AssumedRoleRequest request){ - GenerateAccessTokenRequest.Builder accessTokenRequestBuilder = GenerateAccessTokenRequest.newBuilder() - .setName("projects/-/serviceAccounts/" + request.getRole()) - .addAllScope(List.of(scope)); - if (request.getExpiration() > 0) { - accessTokenRequestBuilder.setLifetime(Duration.newBuilder().setSeconds(request.getExpiration())); + try { + // Create credentials for the service account + GoogleCredentials sourceCredentials = getCredentials(); + + // If service account impersonation is needed, use ImpersonatedCredentials + if (request.getRole() != null && !request.getRole().isEmpty()) { + ImpersonatedCredentials.Builder impersonatedBuilder = ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal(request.getRole()) + .setScopes(List.of(scope)); + + if (request.getExpiration() > 0) { + impersonatedBuilder.setLifetime(request.getExpiration()); + } + + // Set custom HTTP transport if available + if (httpTransportFactory != null) { + impersonatedBuilder.setHttpTransportFactory(httpTransportFactory); + } + + sourceCredentials = impersonatedBuilder.build(); + } + + // If credential scope is provided, apply downscoping + if (request.getCredentialScope() != null) { + // Convert cloud-agnostic CredentialScope to GCP CredentialAccessBoundary + CredentialAccessBoundary gcpAccessBoundary = convertToGcpAccessBoundary(request.getCredentialScope()); + + // Create downscoped credentials with the access boundary + DownscopedCredentials.Builder downscopedBuilder = DownscopedCredentials.newBuilder() + .setSourceCredential(sourceCredentials) + .setCredentialAccessBoundary(gcpAccessBoundary); + DownscopedCredentials downscopedCredentials = downscopedBuilder.build(); + + // Get the downscoped access token + downscopedCredentials.refreshIfExpired(); + AccessToken accessToken = downscopedCredentials.getAccessToken(); + return new StsCredentials(StringUtils.EMPTY, StringUtils.EMPTY, accessToken.getTokenValue()); + } + + // No downscoping - refresh and return the credentials + sourceCredentials.refreshIfExpired(); + AccessToken accessToken = sourceCredentials.getAccessToken(); + return new StsCredentials(StringUtils.EMPTY, StringUtils.EMPTY, accessToken.getTokenValue()); + } catch (IOException e) { + throw new SubstrateSdkException("Failed to create credentials", e); } - GenerateAccessTokenResponse response = this.stsClient.generateAccessToken(accessTokenRequestBuilder.build()); - return new StsCredentials(StringUtils.EMPTY, StringUtils.EMPTY, response.getAccessToken()); } @Override @@ -179,12 +314,16 @@ public Builder self() { return this; } - public GcpSts build(IamCredentialsClient stsClient, GoogleCredentials credentials) { - return new GcpSts(this, stsClient, credentials); + public GcpSts build(GoogleCredentials credentials) { + return new GcpSts(this, credentials); + } + + public GcpSts build(HttpTransportFactory httpTransportFactory) { + return new GcpSts(this, httpTransportFactory); } - public GcpSts build(IamCredentialsClient stsClient) { - return new GcpSts(this, stsClient); + public GcpSts build(GoogleCredentials credentials, HttpTransportFactory httpTransportFactory) { + return new GcpSts(this, credentials, httpTransportFactory); } @Override diff --git a/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsIT.java b/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsIT.java index 85170d0bd..e4c660db5 100644 --- a/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsIT.java +++ b/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsIT.java @@ -1,18 +1,14 @@ package com.salesforce.multicloudj.sts.gcp; -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.api.client.http.HttpTransport; +import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.GoogleCredentials; -import com.google.cloud.iam.credentials.v1.IamCredentialsClient; -import com.google.cloud.iam.credentials.v1.IamCredentialsSettings; import com.salesforce.multicloudj.common.gcp.GcpConstants; import com.salesforce.multicloudj.common.gcp.util.MockGoogleCredentialsFactory; import com.salesforce.multicloudj.common.gcp.util.TestsUtilGcp; import com.salesforce.multicloudj.sts.client.AbstractStsIT; import com.salesforce.multicloudj.sts.driver.AbstractSts; -import org.junit.jupiter.api.Assertions; -import java.io.IOException; import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class GcpStsIT extends AbstractStsIT { @@ -22,30 +18,20 @@ protected Harness createHarness() { } public static class HarnessImpl implements AbstractStsIT.Harness { - IamCredentialsClient client; int port = ThreadLocalRandom.current().nextInt(1000, 10000); + @Override public AbstractSts createStsDriver(boolean longTermCredentials) { boolean isRecordingEnabled = System.getProperty("record") != null; - // Transport channel provider to WireMock proxy - TransportChannelProvider channelProvider = TestsUtilGcp.getTransportChannelProvider(port); - IamCredentialsSettings.Builder settingsBuilder = IamCredentialsSettings.newBuilder() - .setTransportChannelProvider(channelProvider); - try { - if (isRecordingEnabled) { - // Live recording path – rely on real ADC - client = IamCredentialsClient.create(settingsBuilder.build()); - return new GcpSts().builder().build(client); - } else { - // Replay path - inject mock credentials - GoogleCredentials mockCreds = MockGoogleCredentialsFactory.createMockCredentials(); - settingsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(mockCreds)); - client = IamCredentialsClient.create(settingsBuilder.build()); - return new GcpSts().builder().build(client, mockCreds); - } - } catch (IOException e) { - Assertions.fail("Failed to create IAM client", e); - return null; + + HttpTransport httpTransport = TestsUtilGcp.getHttpTransport(port); + HttpTransportFactory httpTransportFactory = () -> httpTransport; + + if (isRecordingEnabled) { + return new GcpSts().builder().build(httpTransportFactory); + } else { + GoogleCredentials mockCreds = MockGoogleCredentialsFactory.createMockCredentials(); + return new GcpSts().builder().build(mockCreds, httpTransportFactory); } } @@ -81,7 +67,7 @@ public boolean supportsGetAccessToken() { @Override public void close() { - client.close(); + // No client to close } } } diff --git a/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsTest.java b/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsTest.java index f28688928..f3787856c 100644 --- a/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsTest.java +++ b/sts/sts-gcp/src/test/java/com/salesforce/multicloudj/sts/gcp/GcpStsTest.java @@ -1,12 +1,10 @@ package com.salesforce.multicloudj.sts.gcp; import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.CredentialAccessBoundary; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdToken; import com.google.auth.oauth2.IdTokenProvider; -import com.google.cloud.iam.credentials.v1.GenerateAccessTokenRequest; -import com.google.cloud.iam.credentials.v1.GenerateAccessTokenResponse; -import com.google.cloud.iam.credentials.v1.IamCredentialsClient; import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.StatusCode; import com.salesforce.multicloudj.common.exceptions.DeadlineExceededException; @@ -20,6 +18,7 @@ import com.salesforce.multicloudj.common.exceptions.UnknownException; import com.salesforce.multicloudj.common.exceptions.UnSupportedOperationException; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.CallerIdentity; import com.salesforce.multicloudj.sts.model.GetAccessTokenRequest; import com.salesforce.multicloudj.sts.model.GetCallerIdentityRequest; @@ -32,31 +31,27 @@ import org.mockito.MockedStatic; import java.io.IOException; +import java.lang.reflect.Method; import java.util.Collection; public class GcpStsTest { - private static IamCredentialsClient mockStsClient; private static GoogleCredentials mockGoogleCredentials; private static GoogleCredentials mockGoogleCredentialsWithIdToken; @BeforeAll public static void setUp() throws IOException { - mockStsClient = Mockito.mock(IamCredentialsClient.class); mockGoogleCredentials = Mockito.mock(GoogleCredentials.class); // Create a mock that is both GoogleCredentials and IdTokenProvider mockGoogleCredentialsWithIdToken = Mockito.mock(GoogleCredentials.class, Mockito.withSettings().extraInterfaces(IdTokenProvider.class)); - GenerateAccessTokenResponse mockAccessTokenResponse = Mockito.mock(GenerateAccessTokenResponse.class); AccessToken mockAccessToken = Mockito.mock(AccessToken.class); // Create a real IdToken instead of mocking it String mockJwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiJtb2NrLXVzZXIiLCJhdWQiOiJtdWx0aWNsb3VkaiIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxMjM0NTY3ODkwfQ.mock-signature"; IdToken mockIdToken = IdToken.create(mockJwt); - Mockito.when(mockStsClient.generateAccessToken(Mockito.any(GenerateAccessTokenRequest.class))).thenReturn(mockAccessTokenResponse); - Mockito.when(mockAccessTokenResponse.getAccessToken()).thenReturn("testAccessToken"); Mockito.when(mockGoogleCredentials.createScoped(Mockito.any(Collection.class))).thenReturn(mockGoogleCredentials); Mockito.doNothing().when(mockGoogleCredentials).refreshIfExpired(); Mockito.when(mockGoogleCredentials.getAccessToken()).thenReturn(mockAccessToken); @@ -70,13 +65,21 @@ public static void setUp() throws IOException { } @Test - public void TestAssumedRoleSts() { - GcpSts sts = new GcpSts().builder().build(mockStsClient); - AssumedRoleRequest request = AssumedRoleRequest.newBuilder().withRole("testRole").withSessionName("testSession").build(); + public void TestAssumedRoleSts() throws IOException { + // Reset the mock to ensure no interference from other tests + Mockito.reset(mockGoogleCredentials); + Mockito.when(mockGoogleCredentials.createScoped(Mockito.any(Collection.class))).thenReturn(mockGoogleCredentials); + Mockito.doNothing().when(mockGoogleCredentials).refreshIfExpired(); + Mockito.when(mockGoogleCredentials.getAccessToken()).thenReturn(Mockito.mock(AccessToken.class)); + Mockito.when(mockGoogleCredentials.getAccessToken().getTokenValue()).thenReturn("testAccessTokenValue"); + + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); + // Test without role to avoid ImpersonatedCredentials (which can't be easily mocked) + AssumedRoleRequest request = AssumedRoleRequest.newBuilder().withSessionName("testSession").build(); StsCredentials credentials = sts.assumeRole(request); Assertions.assertEquals(StringUtils.EMPTY, credentials.getAccessKeyId()); Assertions.assertEquals(StringUtils.EMPTY, credentials.getAccessKeySecret()); - Assertions.assertEquals("testAccessToken", credentials.getSecurityToken()); + Assertions.assertEquals("testAccessTokenValue", credentials.getSecurityToken()); } @Test @@ -84,7 +87,7 @@ public void TestGetCallerIdentitySts() { try (MockedStatic mockedGoogleCreds = Mockito.mockStatic(GoogleCredentials.class)) { mockedGoogleCreds.when(GoogleCredentials::getApplicationDefault).thenReturn(mockGoogleCredentialsWithIdToken); - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentialsWithIdToken); CallerIdentity identity = sts.getCallerIdentity(GetCallerIdentityRequest.builder().build()); Assertions.assertEquals(StringUtils.EMPTY, identity.getUserId()); Assertions.assertTrue(identity.getCloudResourceName().startsWith("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9")); @@ -97,7 +100,7 @@ public void TestGetCallerIdentityWithCustomAud() { try (MockedStatic mockedGoogleCreds = Mockito.mockStatic(GoogleCredentials.class)) { mockedGoogleCreds.when(GoogleCredentials::getApplicationDefault).thenReturn(mockGoogleCredentialsWithIdToken); - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentialsWithIdToken); CallerIdentity identity = sts.getCallerIdentity(GetCallerIdentityRequest.builder().aud("customAudience").build()); Assertions.assertEquals(StringUtils.EMPTY, identity.getUserId()); Assertions.assertTrue(identity.getCloudResourceName().startsWith("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9")); @@ -110,7 +113,7 @@ public void TestGetCallerIdentityStsThrowsException() throws IOException { try (MockedStatic mockedGoogleCreds = Mockito.mockStatic(GoogleCredentials.class)) { mockedGoogleCreds.when(GoogleCredentials::getApplicationDefault).thenReturn(mockGoogleCredentialsWithIdToken); - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentialsWithIdToken); Mockito.doThrow(new IOException("Test error")).when(mockGoogleCredentialsWithIdToken).refreshIfExpired(); Assertions.assertThrows(RuntimeException.class, () -> { @@ -126,7 +129,7 @@ public void TestGetSessionTokenSts() throws IOException { Mockito.when(mockGoogleCredentials.createScoped(Mockito.any(Collection.class))).thenReturn(mockGoogleCredentials); Mockito.doNothing().when(mockGoogleCredentials).refreshIfExpired(); - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentialsWithIdToken); GetAccessTokenRequest request = GetAccessTokenRequest.newBuilder().withDurationSeconds(60).build(); StsCredentials credentials = sts.getAccessToken(request); Assertions.assertEquals(StringUtils.EMPTY, credentials.getAccessKeyId()); @@ -140,7 +143,7 @@ public void TestGetSessionTokenStsThrowsException() throws IOException { try (MockedStatic mockedGoogleCreds = Mockito.mockStatic(GoogleCredentials.class)) { mockedGoogleCreds.when(GoogleCredentials::getApplicationDefault).thenReturn(mockGoogleCredentials); - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); Mockito.doThrow(new IOException("Test error")).when(mockGoogleCredentials).refreshIfExpired(); Assertions.assertThrows(RuntimeException.class, () -> { @@ -151,21 +154,14 @@ public void TestGetSessionTokenStsThrowsException() throws IOException { @Test public void TestGcpStsConstructorWithBuilder() { - try (MockedStatic mockedIamClient = Mockito.mockStatic(IamCredentialsClient.class)) { - mockedIamClient.when(IamCredentialsClient::create).thenReturn(mockStsClient); - GcpSts sts = new GcpSts(new GcpSts().builder()); - Assertions.assertNotNull(sts); - Assertions.assertEquals("gcp", sts.getProviderId()); - mockedIamClient.when(IamCredentialsClient::create).thenThrow(new IOException("Failed to create client")); - Assertions.assertThrows(RuntimeException.class, () -> { - new GcpSts(new GcpSts().builder()); - }); - } + GcpSts sts = new GcpSts(new GcpSts().builder()); + Assertions.assertNotNull(sts); + Assertions.assertEquals("gcp", sts.getProviderId()); } @Test public void TestGetExceptionWithApiException() { - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); // Test various status codes assertExceptionMapping(sts, StatusCode.Code.CANCELLED, UnknownException.class); @@ -188,14 +184,14 @@ public void TestGetExceptionWithApiException() { @Test public void TestGetExceptionWithNonApiException() { - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); Class exceptionClass = sts.getException(new RuntimeException("Test error")); Assertions.assertEquals(UnknownException.class, exceptionClass); } @Test public void TestAssumeRoleWithWebIdentityReturnsNull() { - GcpSts sts = new GcpSts().builder().build(mockStsClient); + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest request = com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest.builder() .role("testRole") @@ -204,6 +200,84 @@ public void TestAssumeRoleWithWebIdentityReturnsNull() { Assertions.assertThrows(UnSupportedOperationException.class, () -> sts.assumeRoleWithWebIdentity(request)); } + @Test + public void TestAssumedRoleStsWithCredentialScopeConversion() throws Exception { + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); + CredentialScope.AvailabilityCondition condition = CredentialScope.AvailabilityCondition.builder() + .resourcePrefix("storage://my-bucket/documents/") + .title("Limit to documents folder") + .description("Only allow access to objects in the documents folder") + .build(); + // Create a cloud-agnostic CredentialScope using storage:// format + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://test-bucket") + .availablePermission("storage:GetObject") + .availablePermission("storage:PutObject") + .availablePermission("storage:DeleteObject") + .availablePermission("storage:ListBucket") + .availabilityCondition(condition) + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + // Test conversion logic using reflection to access private method + Method convertMethod = GcpSts.class.getDeclaredMethod("convertToGcpAccessBoundary", CredentialScope.class); + convertMethod.setAccessible(true); + CredentialAccessBoundary boundary = + (CredentialAccessBoundary) convertMethod.invoke(sts, credentialScope); + + // Verify the converted boundary structure + Assertions.assertNotNull(boundary); + Assertions.assertEquals(1, boundary.getAccessBoundaryRules().size()); + + CredentialAccessBoundary.AccessBoundaryRule boundaryRule = boundary.getAccessBoundaryRules().get(0); + + // Verify resource conversion: storage://test-bucket -> //storage.googleapis.com/projects/_/buckets/test-bucket + Assertions.assertEquals("//storage.googleapis.com/projects/_/buckets/test-bucket", boundaryRule.getAvailableResource()); + + // Verify permission conversion: storage:GetObject -> inRole:roles/storage.objectViewer, storage:PutObject -> inRole:roles/storage.objectCreator + Assertions.assertEquals(4, boundaryRule.getAvailablePermissions().size()); + Assertions.assertTrue(boundaryRule.getAvailablePermissions().contains("inRole:roles/storage.objectViewer")); + Assertions.assertTrue(boundaryRule.getAvailablePermissions().contains("inRole:roles/storage.objectCreator")); + + // Verify condition conversion + CredentialAccessBoundary.AccessBoundaryRule.AvailabilityCondition gcpCondition = + boundaryRule.getAvailabilityCondition(); + Assertions.assertNotNull(gcpCondition); + Assertions.assertEquals("Limit to documents folder", gcpCondition.getTitle()); + Assertions.assertEquals("Only allow access to objects in the documents folder", gcpCondition.getDescription()); + Assertions.assertEquals("resource.name.startsWith('projects/_/buckets/my-bucket/objects/documents/')", gcpCondition.getExpression()); + } + + @Test + public void TestAssumedRoleStsWithCredentialScopeExecutionWithMockedCredentials() throws IOException { + GcpSts sts = new GcpSts().builder().build(mockGoogleCredentials); + + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://test-bucket") + .availablePermission("storage:GetObject") + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withSessionName("testSession") + .withCredentialScope(credentialScope) + .build(); + + try { + sts.assumeRole(request); + Assertions.fail("Expected exception from DownscopedCredentials"); + } catch (IllegalArgumentException e) { + Assertions.assertNotNull(e, "mock credentials should throw for downscoped credentials"); + } + } + + private void assertExceptionMapping(GcpSts sts, StatusCode.Code statusCode, Class expectedExceptionClass) { ApiException apiException = Mockito.mock(ApiException.class); StatusCode mockStatusCode = Mockito.mock(StatusCode.class); From 9f3d6e2a8727865234a5c8617e71f40191001925 Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Thu, 8 Jan 2026 16:03:01 -0800 Subject: [PATCH 2/6] gcp sts access boundary --- sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json b/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json index 9cb66db16..7ce7ce93c 100644 --- a/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json +++ b/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json @@ -2,7 +2,7 @@ "id" : "68ba6716-6665-4c8f-91df-5a9455fef561", "name" : "", "request" : { - "urlPattern" : "/\\?SecurityToken=.*&SignatureVersion=1.0&Action=GetCallerIdentity&Format=JSON&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai&Timestamp=.*", + "urlPattern" : "/\\?SecurityToken=.*&SignatureVersion=1.0&Action=GetCallerIdentity&Format=JSON&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai&Timestamp=.*&DurationSeconds=3600", "method" : "POST" }, "response" : { From 1bde64971647d162bdf78d50cf1744a2a3ee62a5 Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Thu, 8 Jan 2026 16:06:58 -0800 Subject: [PATCH 3/6] gcp sts access boundary --- sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json | 2 +- sts/sts-ali/src/test/resources/mappings/post-iazmc9td50.json | 2 +- .../com/salesforce/multicloudj/sts/client/AbstractStsIT.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json b/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json index 7ce7ce93c..9cb66db16 100644 --- a/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json +++ b/sts/sts-ali/src/test/resources/mappings/post--iwgwfy0yey.json @@ -2,7 +2,7 @@ "id" : "68ba6716-6665-4c8f-91df-5a9455fef561", "name" : "", "request" : { - "urlPattern" : "/\\?SecurityToken=.*&SignatureVersion=1.0&Action=GetCallerIdentity&Format=JSON&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai&Timestamp=.*&DurationSeconds=3600", + "urlPattern" : "/\\?SecurityToken=.*&SignatureVersion=1.0&Action=GetCallerIdentity&Format=JSON&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai&Timestamp=.*", "method" : "POST" }, "response" : { diff --git a/sts/sts-ali/src/test/resources/mappings/post-iazmc9td50.json b/sts/sts-ali/src/test/resources/mappings/post-iazmc9td50.json index 62dab9888..2e50cd8bd 100644 --- a/sts/sts-ali/src/test/resources/mappings/post-iazmc9td50.json +++ b/sts/sts-ali/src/test/resources/mappings/post-iazmc9td50.json @@ -2,7 +2,7 @@ "id" : "90d18175-4f86-4190-9f40-5583fb96ea8c", "name" : "", "request" : { - "urlPattern": "/\\?Action=AssumeRole&Timestamp=.*&RoleArn=acs%3Aram%3A%3A1936276257662232%3Arole%2Fchameleon-test-role&SecurityToken=.*&SignatureVersion=1.0&Format=JSON&RoleSessionName=any-session&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*%3D&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai", + "urlPattern": "/\\?Action=AssumeRole&DurationSeconds=3600&Timestamp=.*&RoleArn=acs%3Aram%3A%3A1936276257662232%3Arole%2Fchameleon-test-role&SecurityToken=.*&SignatureVersion=1.0&Format=JSON&RoleSessionName=any-session&SignatureNonce=.*&Version=2015-04-01&AccessKeyId=.*&Signature=.*%3D&SignatureMethod=HMAC-SHA1&RegionId=cn-shanghai", "method" : "POST" }, "response" : { diff --git a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java index 7884d6a43..e9cb49627 100644 --- a/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java +++ b/sts/sts-client/src/test/java/com/salesforce/multicloudj/sts/client/AbstractStsIT.java @@ -113,7 +113,7 @@ public void testAssumeRole() { AbstractSts sts = harness.createStsDriver(false); StsClient stsClient = new StsClient(sts); AssumedRoleRequest request = AssumedRoleRequest.newBuilder().withRole(harness.getRoleName()).withExpiration(3600).withSessionName("any-session").build(); - StsCredentials credentials = stsClient. getAssumeRoleCredentials(request); + StsCredentials credentials = stsClient.getAssumeRoleCredentials(request); Assertions.assertNotNull(credentials, "Credentials shouldn't be empty"); Assertions.assertNotNull(credentials.getAccessKeySecret()); Assertions.assertNotNull(credentials.getAccessKeyId()); From 21d810c8336a474345844983f738459abe863858 Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Thu, 8 Jan 2026 16:22:30 -0800 Subject: [PATCH 4/6] gcp sts access boundary --- sts/sts-aws/pom.xml | 5 +++ .../multicloudj/sts/aws/AwsSts.java | 37 ++++--------------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/sts/sts-aws/pom.xml b/sts/sts-aws/pom.xml index 8974d1236..40ad4283d 100644 --- a/sts/sts-aws/pom.xml +++ b/sts/sts-aws/pom.xml @@ -20,6 +20,11 @@ 3.12.1 test + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + org.mockito mockito-core diff --git a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java index 6e24cd5ab..60501c0ab 100644 --- a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java +++ b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java @@ -1,7 +1,10 @@ package com.salesforce.multicloudj.sts.aws; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auto.service.AutoService; import com.salesforce.multicloudj.common.aws.CommonErrorCodeMapping; +import com.salesforce.multicloudj.common.exceptions.InvalidArgumentException; import com.salesforce.multicloudj.common.exceptions.SubstrateSdkException; import com.salesforce.multicloudj.common.exceptions.UnAuthorizedException; import com.salesforce.multicloudj.common.exceptions.UnknownException; @@ -121,7 +124,6 @@ private String convertToAwsPolicy(CredentialScope credentialScope) { policy.put("Version", "2012-10-17"); policy.put("Statement", statements); - // Convert to JSON string return toJsonString(policy); } @@ -179,36 +181,11 @@ private Map convertConditionToAwsCondition( * Converts Map to JSON string. */ private String toJsonString(Map map) { - // Simple JSON serialization - in production, use a proper JSON library - StringBuilder json = new StringBuilder("{"); - boolean first = true; - for (Map.Entry entry : map.entrySet()) { - if (!first) json.append(","); - first = false; - json.append("\"").append(entry.getKey()).append("\":"); - json.append(toJsonValue(entry.getValue())); + try { + return new ObjectMapper().writeValueAsString(map); + } catch (JsonProcessingException e) { + throw new InvalidArgumentException("scoped credentials is not in right format", e); } - json.append("}"); - return json.toString(); - } - - @SuppressWarnings("unchecked") - private String toJsonValue(Object value) { - if (value == null) { - return "null"; - } else if (value instanceof String) { - return "\"" + value + "\""; - } else if (value instanceof Number || value instanceof Boolean) { - return value.toString(); - } else if (value instanceof List) { - List list = (List) value; - return "[" + list.stream() - .map(this::toJsonValue) - .collect(Collectors.joining(",")) + "]"; - } else if (value instanceof Map) { - return toJsonString((Map) value); - } - return "\"" + value + "\""; } @Override From 70f51f6c92381945063b33e80438d3f6b894c21e Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Fri, 9 Jan 2026 09:52:36 -0800 Subject: [PATCH 5/6] gcp sts access boundary --- .../com/salesforce/multicloudj/blob/gcp/GcpTransformer.java | 3 --- .../main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/blob/blob-gcp/src/main/java/com/salesforce/multicloudj/blob/gcp/GcpTransformer.java b/blob/blob-gcp/src/main/java/com/salesforce/multicloudj/blob/gcp/GcpTransformer.java index 2007300b3..f9309b6ac 100644 --- a/blob/blob-gcp/src/main/java/com/salesforce/multicloudj/blob/gcp/GcpTransformer.java +++ b/blob/blob-gcp/src/main/java/com/salesforce/multicloudj/blob/gcp/GcpTransformer.java @@ -1,6 +1,5 @@ package com.salesforce.multicloudj.blob.gcp; -import com.google.api.gax.paging.Page; import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; @@ -19,11 +18,9 @@ import com.salesforce.multicloudj.blob.driver.MultipartUpload; import com.salesforce.multicloudj.blob.driver.MultipartUploadRequest; import com.salesforce.multicloudj.blob.driver.PresignedUrlRequest; -import com.salesforce.multicloudj.blob.driver.UploadPartResponse; import com.salesforce.multicloudj.blob.driver.UploadRequest; import com.salesforce.multicloudj.blob.driver.UploadResponse; import com.salesforce.multicloudj.common.exceptions.InvalidArgumentException; -import com.salesforce.multicloudj.common.exceptions.UnSupportedOperationException; import com.salesforce.multicloudj.common.util.HexUtil; import lombok.Getter; import org.apache.commons.lang3.tuple.ImmutablePair; diff --git a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java index 60501c0ab..f5d865a8d 100644 --- a/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java +++ b/sts/sts-aws/src/main/java/com/salesforce/multicloudj/sts/aws/AwsSts.java @@ -34,12 +34,11 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.stream.Collectors; @AutoService(AbstractSts.class) public class AwsSts extends AbstractSts { - + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private StsClient stsClient; public AwsSts(Builder builder) { @@ -182,7 +181,7 @@ private Map convertConditionToAwsCondition( */ private String toJsonString(Map map) { try { - return new ObjectMapper().writeValueAsString(map); + return OBJECT_MAPPER.writeValueAsString(map); } catch (JsonProcessingException e) { throw new InvalidArgumentException("scoped credentials is not in right format", e); } From bad3bc7cfb3afb613f2e68902bebea55ee217a8c Mon Sep 17 00:00:00 2001 From: sandeepvinayak Date: Fri, 9 Jan 2026 09:59:03 -0800 Subject: [PATCH 6/6] gcp sts access boundary --- .../multicloudj/sts/driver/AbstractSts.java | 82 +++++++++++- .../sts/driver/AbstractStsTest.java | 124 ++++++++++++++++++ 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/driver/AbstractSts.java b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/driver/AbstractSts.java index 4172e0996..8de041d64 100644 --- a/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/driver/AbstractSts.java +++ b/sts/sts-client/src/main/java/com/salesforce/multicloudj/sts/driver/AbstractSts.java @@ -4,13 +4,14 @@ import com.salesforce.multicloudj.sts.model.AssumeRoleWebIdentityRequest; import com.salesforce.multicloudj.sts.model.AssumedRoleRequest; import com.salesforce.multicloudj.sts.model.CallerIdentity; +import com.salesforce.multicloudj.sts.model.CredentialScope; import com.salesforce.multicloudj.sts.model.GetAccessTokenRequest; import com.salesforce.multicloudj.sts.model.GetCallerIdentityRequest; import com.salesforce.multicloudj.sts.model.StsCredentials; -import lombok.Getter; - import java.net.URI; +import lombok.Getter; + /** * Abstract base class for Security Token Service (STS) implementations. * This class is internal for SDK and all the providers for STS implementations @@ -22,6 +23,7 @@ public abstract class AbstractSts implements Provider { /** * Constructs an AbstractSts instance using a Builder. + * * @param builder The Builder instance to use for construction. */ public AbstractSts(Builder builder) { @@ -30,6 +32,7 @@ public AbstractSts(Builder builder) { /** * Constructs an AbstractSts instance with specified provider ID and region. + * * @param providerId The ID of the provider. * @param region The region for the STS. */ @@ -48,15 +51,62 @@ public String getProviderId() { /** * Assumes a role and returns the credentialsOverrider. + * * @param request The AssumedRoleRequest containing role information. * @return StsCredentials for the assumed role. + * @throws IllegalArgumentException if credential scope contains non-storage + * permissions or resources */ public StsCredentials assumeRole(AssumedRoleRequest request) { + validateCredentialScope(request.getCredentialScope()); return getSTSCredentialsWithAssumeRole(request); } + /** + * Validates that CredentialScope only contains storage-related permissions and resources. + * + * @param credentialScope The CredentialScope to validate (can be null) + * @throws IllegalArgumentException if scope contains non-storage permissions or resources + */ + private void validateCredentialScope(CredentialScope credentialScope) { + if (credentialScope == null) { + return; // null is valid - no scope restrictions + } + + for (CredentialScope.ScopeRule rule : credentialScope.getRules()) { + // Validate resource is storage-only + String resource = rule.getAvailableResource(); + if (resource != null && !resource.startsWith("storage://")) { + throw new IllegalArgumentException( + "Credential scope resource must start with 'storage://'. Found: " + + resource); + } + + // Validate all permissions are storage-only + for (String permission : rule.getAvailablePermissions()) { + if (!permission.startsWith("storage:")) { + throw new IllegalArgumentException( + "Credential scope permission must start with 'storage:'. Found: " + + permission); + } + } + + // Validate condition resourcePrefix is storage-only (if present) + if (rule.getAvailabilityCondition() != null) { + String resourcePrefix = rule.getAvailabilityCondition().getResourcePrefix(); + if (resourcePrefix != null && !resourcePrefix.startsWith("storage://")) { + throw new IllegalArgumentException( + "Credential scope condition resourcePrefix must start with " + + "'storage://'. Found: " + resourcePrefix); + } + } + } + } + /** * Retrieves the caller identity. + * + * @param request The GetCallerIdentityRequest. * @return The CallerIdentity of the current caller. */ public CallerIdentity getCallerIdentity(GetCallerIdentityRequest request) { @@ -65,6 +115,7 @@ public CallerIdentity getCallerIdentity(GetCallerIdentityRequest request) { /** * Retrieves an access token. + * * @param request The GetAccessTokenRequest containing token request details. * @return StsCredentials containing the access token. */ @@ -74,7 +125,9 @@ public StsCredentials getAccessToken(GetAccessTokenRequest request) { /** * Assumes a role with web identity and returns the credentials. - * @param request The AssumeRoleWithWebIdentityRequest containing role and web identity token information. + * + * @param request The AssumeRoleWithWebIdentityRequest containing role and web identity + * token information. * @return StsCredentials for the assumed role with web identity. */ public StsCredentials assumeRoleWithWebIdentity(AssumeRoleWebIdentityRequest request) { @@ -83,10 +136,12 @@ public StsCredentials assumeRoleWithWebIdentity(AssumeRoleWebIdentityRequest req /** * Abstract builder class for AbstractSts implementations. + * * @param The concrete implementation type of AbstractSts. * @param The concrete implementation type of Builder. */ - public abstract static class Builder> implements Provider.Builder { + public abstract static class Builder> + implements Provider.Builder { @Getter protected String region; @Getter @@ -95,6 +150,7 @@ public abstract static class Builder { + sts.assumeRole(request); + }); + assertEquals("Credential scope resource must start with 'storage://'. Found: compute://my-instance", + exception.getMessage()); + } + + @Test + public void testInvalidCredentialScopeWithNonStoragePermission() { + TestSts sts = builder.build(); + + // Invalid: non-storage permission + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://my-bucket") + .availablePermission("compute:StartInstance") + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withRole("test-role") + .withCredentialScope(credentialScope) + .build(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + sts.assumeRole(request); + }); + assertEquals("Credential scope permission must start with 'storage:'. Found: compute:StartInstance", + exception.getMessage()); + } + + @Test + public void testInvalidCredentialScopeWithNonStorageConditionPrefix() { + TestSts sts = builder.build(); + + // Invalid: non-storage condition resourcePrefix + CredentialScope.AvailabilityCondition condition = CredentialScope.AvailabilityCondition.builder() + .resourcePrefix("compute://my-instance/logs/") + .build(); + + CredentialScope.ScopeRule rule = CredentialScope.ScopeRule.builder() + .availableResource("storage://my-bucket") + .availablePermission("storage:GetObject") + .availabilityCondition(condition) + .build(); + + CredentialScope credentialScope = CredentialScope.builder() + .rule(rule) + .build(); + + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withRole("test-role") + .withCredentialScope(credentialScope) + .build(); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + sts.assumeRole(request); + }); + assertEquals("Credential scope condition resourcePrefix must start with 'storage://'. Found: compute://my-instance/logs/", + exception.getMessage()); + } + + @Test + public void testNullCredentialScopeIsValid() { + TestSts sts = builder.build(); + + // Null credential scope is valid + AssumedRoleRequest request = AssumedRoleRequest.newBuilder() + .withRole("test-role") + .build(); + + // Should not throw + StsCredentials credentials = sts.assumeRole(request); + assertNotNull(credentials); + } }