Skip to content

Commit 7b1e45d

Browse files
committed
iam: implement substrate neutral policy document model
1 parent 8c636ee commit 7b1e45d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1881
-298
lines changed

examples/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@
8181
<artifactId>docstore-aws</artifactId>
8282
<version>${project.version}</version>
8383
</dependency>
84+
<dependency>
85+
<groupId>com.salesforce.multicloudj</groupId>
86+
<artifactId>iam-aws</artifactId>
87+
<version>${project.version}</version>
88+
</dependency>
89+
<dependency>
90+
<groupId>com.salesforce.multicloudj</groupId>
91+
<artifactId>iam-gcp</artifactId>
92+
<version>${project.version}</version>
93+
</dependency>
8494
<dependency>
8595
<groupId>com.salesforce.multicloudj</groupId>
8696
<artifactId>dbbackuprestore-aws</artifactId>

examples/src/main/java/com/salesforce/multicloudj/iam/Main.java

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public class Main {
3939
private static final String DEFAULT_PROVIDER = "gcp";
4040
private static final String REGION = "us-central-1";
4141
private static final String TENANT_ID = "projects/substrate-sdk-gcp-poc1";
42-
private static final String SERVICE_ACCOUNT = "serviceAccount:multicloudjexample@substrate-sdk-gcp-poc1.iam.gserviceaccount.com";
42+
private static final String SERVICE_ACCOUNT = "serviceAccount:chameleon@substrate-sdk-gcp-poc1.iam.gserviceaccount.com";
4343

4444
// Demo settings
4545
private static final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
@@ -294,7 +294,7 @@ private void demonstratePolicyManagement() {
294294
waitForEnter("Press Enter to remove the storage policy (check cloud console before proceeding)...");
295295
showInfo("Removing storage policy...");
296296
try {
297-
removePolicy("roles/storage.admin");
297+
removePolicy("storage-policy");
298298
showSuccess("Successfully removed storage policy");
299299
} catch (Exception e) {
300300
showError("Failed to remove policy: " + e.getMessage());
@@ -406,18 +406,34 @@ private void deleteIdentity(String identityName) throws Exception {
406406
}
407407

408408
/**
409-
* Attach a storage policy using a single comprehensive GCP IAM role.
410-
* Using roles/storage.admin which provides full storage permissions.
409+
* Attach a storage policy using substrate-neutral actions.
410+
* These actions will be translated to cloud-specific formats:
411+
* - AWS: storage:GetObject → s3:GetObject, storage:* → s3:*
412+
* - GCP: storage:GetObject → roles/storage.objectViewer, storage:* → roles/storage.admin
411413
*/
412414
private void attachStoragePolicy() throws Exception {
413415
try (IamClient iamClient = initializeClient()) {
414-
// Create a policy document using a single comprehensive GCP IAM role
416+
// Create a comprehensive policy document using substrate-neutral actions
415417
PolicyDocument policyDocument = PolicyDocument.builder()
416418
.version("2024-01-01")
419+
.statement(Statement.builder()
420+
.sid("StorageReadAccess")
421+
.effect("Allow")
422+
.action("storage:GetObject")
423+
.action("storage:ListBucket")
424+
.resource("storage://demo-bucket/*")
425+
.build())
426+
.statement(Statement.builder()
427+
.sid("StorageWriteAccess")
428+
.effect("Allow")
429+
.action("storage:PutObject")
430+
.action("storage:DeleteObject")
431+
.resource("storage://demo-bucket/*")
432+
.build())
417433
.statement(Statement.builder()
418434
.sid("StorageFullAccess")
419435
.effect("Allow")
420-
.action("roles/storage.admin")
436+
.action("storage:*")
421437
.resource("storage://demo-bucket/*")
422438
.build())
423439
.build();
@@ -478,4 +494,4 @@ private void removePolicy(String policyName) throws Exception {
478494
);
479495
}
480496
}
481-
}
497+
}
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package com.salesforce.multicloudj.iam.aws;
2+
3+
import com.salesforce.multicloudj.common.exceptions.SubstrateSdkException;
4+
import com.salesforce.multicloudj.iam.model.PolicyDocument;
5+
import com.salesforce.multicloudj.iam.model.Statement;
6+
import com.fasterxml.jackson.core.JsonProcessingException;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
9+
import java.util.ArrayList;
10+
import java.util.LinkedHashMap;
11+
import java.util.List;
12+
import java.util.Map;
13+
import java.util.stream.Collectors;
14+
15+
/**
16+
* Translates substrate-neutral PolicyDocument to AWS IAM policy format.
17+
*
18+
* <p>This translator converts substrate-neutral actions, resources, and conditions
19+
* to AWS-specific IAM policy JSON format according to the translation rules defined
20+
* in PolicyDocument documentation.
21+
*
22+
* <p>Translation rules:
23+
* <ul>
24+
* <li>Actions: storage:GetObject → s3:GetObject, compute:CreateInstance → ec2:RunInstances</li>
25+
* <li>Resources: storage://bucket-name/* → arn:aws:s3:::bucket-name/*</li>
26+
* <li>Conditions: stringEquals → StringEquals (capitalize first letter)</li>
27+
* <li>Principals: Wrap in {"AWS": "principal"} or {"Service": "principal"}</li>
28+
* </ul>
29+
*/
30+
public class AwsIamPolicyTranslator {
31+
32+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
33+
private static final String SERVICE_PRINCIPAL_SUFFIX = ".amazonaws.com";
34+
35+
// Action mappings: substrate-neutral → AWS
36+
private static final Map<String, String> ACTION_MAPPINGS = Map.ofEntries(
37+
// Storage actions
38+
Map.entry("storage:GetObject", "s3:GetObject"),
39+
Map.entry("storage:PutObject", "s3:PutObject"),
40+
Map.entry("storage:DeleteObject", "s3:DeleteObject"),
41+
Map.entry("storage:ListBucket", "s3:ListBucket"),
42+
Map.entry("storage:GetBucketLocation", "s3:GetBucketLocation"),
43+
Map.entry("storage:CreateBucket", "s3:CreateBucket"),
44+
Map.entry("storage:DeleteBucket", "s3:DeleteBucket"),
45+
46+
// Compute actions
47+
Map.entry("compute:CreateInstance", "ec2:RunInstances"),
48+
Map.entry("compute:DeleteInstance", "ec2:TerminateInstances"),
49+
Map.entry("compute:StartInstance", "ec2:StartInstances"),
50+
Map.entry("compute:StopInstance", "ec2:StopInstances"),
51+
Map.entry("compute:DescribeInstances", "ec2:DescribeInstances"),
52+
Map.entry("compute:GetInstance", "ec2:DescribeInstances"),
53+
54+
// IAM actions
55+
Map.entry("iam:AssumeRole", "sts:AssumeRole"),
56+
Map.entry("iam:CreateRole", "iam:CreateRole"),
57+
Map.entry("iam:DeleteRole", "iam:DeleteRole"),
58+
Map.entry("iam:GetRole", "iam:GetRole"),
59+
Map.entry("iam:AttachRolePolicy", "iam:AttachRolePolicy"),
60+
Map.entry("iam:DetachRolePolicy", "iam:DetachRolePolicy"),
61+
Map.entry("iam:PutRolePolicy", "iam:PutRolePolicy"),
62+
Map.entry("iam:GetRolePolicy", "iam:GetRolePolicy")
63+
);
64+
65+
// Condition operator mappings: substrate-neutral → AWS
66+
private static final Map<String, String> CONDITION_MAPPINGS = Map.ofEntries(
67+
Map.entry("stringEquals", "StringEquals"),
68+
Map.entry("stringNotEquals", "StringNotEquals"),
69+
Map.entry("stringLike", "StringLike"),
70+
Map.entry("stringNotLike", "StringNotLike"),
71+
Map.entry("numericEquals", "NumericEquals"),
72+
Map.entry("numericNotEquals", "NumericNotEquals"),
73+
Map.entry("numericLessThan", "NumericLessThan"),
74+
Map.entry("numericLessThanEquals", "NumericLessThanEquals"),
75+
Map.entry("numericGreaterThan", "NumericGreaterThan"),
76+
Map.entry("numericGreaterThanEquals", "NumericGreaterThanEquals"),
77+
Map.entry("dateEquals", "DateEquals"),
78+
Map.entry("dateNotEquals", "DateNotEquals"),
79+
Map.entry("dateLessThan", "DateLessThan"),
80+
Map.entry("dateLessThanEquals", "DateLessThanEquals"),
81+
Map.entry("dateGreaterThan", "DateGreaterThan"),
82+
Map.entry("dateGreaterThanEquals", "DateGreaterThanEquals"),
83+
Map.entry("bool", "Bool"),
84+
Map.entry("ipAddress", "IpAddress"),
85+
Map.entry("notIpAddress", "NotIpAddress")
86+
);
87+
88+
/**
89+
* Translates a substrate-neutral PolicyDocument to AWS IAM policy JSON string.
90+
*
91+
* @param policyDocument the substrate-neutral policy document
92+
* @return AWS IAM policy JSON string
93+
* @throws SubstrateSdkException if translation fails
94+
*/
95+
public static String translateToAwsPolicy(PolicyDocument policyDocument) {
96+
Map<String, Object> awsPolicy = new LinkedHashMap<>();
97+
awsPolicy.put("Version", policyDocument.getVersion());
98+
99+
List<Map<String, Object>> awsStatements = new ArrayList<>();
100+
for (Statement statement : policyDocument.getStatements()) {
101+
awsStatements.add(translateStatement(statement));
102+
}
103+
awsPolicy.put("Statement", awsStatements);
104+
105+
try {
106+
return OBJECT_MAPPER.writeValueAsString(awsPolicy);
107+
} catch (JsonProcessingException e) {
108+
throw new SubstrateSdkException("Failed to serialize AWS IAM policy to JSON", e);
109+
}
110+
}
111+
112+
/**
113+
* Translates a single statement from substrate-neutral to AWS format.
114+
*
115+
* @param statement the substrate-neutral statement
116+
* @return AWS IAM statement as a map
117+
* @throws SubstrateSdkException if translation fails
118+
*/
119+
private static Map<String, Object> translateStatement(Statement statement) {
120+
Map<String, Object> awsStatement = new LinkedHashMap<>();
121+
122+
// Add Sid if present
123+
if (statement.getSid() != null && !statement.getSid().isEmpty()) {
124+
awsStatement.put("Sid", statement.getSid());
125+
}
126+
127+
// Add Effect
128+
awsStatement.put("Effect", statement.getEffect());
129+
130+
// Translate and add Principals if present
131+
if (statement.getPrincipals() != null && !statement.getPrincipals().isEmpty()) {
132+
awsStatement.put("Principal", translatePrincipals(statement.getPrincipals()));
133+
}
134+
135+
// Translate and add Actions
136+
List<String> awsActions = statement.getActions().stream()
137+
.map(AwsIamPolicyTranslator::translateAction)
138+
.collect(Collectors.toList());
139+
140+
if (awsActions.size() == 1) {
141+
awsStatement.put("Action", awsActions.get(0));
142+
} else {
143+
awsStatement.put("Action", awsActions);
144+
}
145+
146+
// Translate and add Resources if present
147+
if (statement.getResources() != null && !statement.getResources().isEmpty()) {
148+
List<String> awsResources = statement.getResources().stream()
149+
.map(AwsIamPolicyTranslator::translateResource)
150+
.collect(Collectors.toList());
151+
152+
if (awsResources.size() == 1) {
153+
awsStatement.put("Resource", awsResources.get(0));
154+
} else {
155+
awsStatement.put("Resource", awsResources);
156+
}
157+
}
158+
159+
// Translate and add Conditions if present
160+
if (statement.getConditions() != null && !statement.getConditions().isEmpty()) {
161+
awsStatement.put("Condition", translateConditions(statement.getConditions()));
162+
}
163+
164+
return awsStatement;
165+
}
166+
167+
/**
168+
* Translates substrate-neutral action to AWS action.
169+
* Supports wildcard actions like storage:*, compute:*, iam:*.
170+
*
171+
* @param action the substrate-neutral action
172+
* @return AWS action
173+
* @throws SubstrateSdkException if action is unknown
174+
*/
175+
private static String translateAction(String action) {
176+
// Handle wildcard actions (e.g., storage:*, compute:*, iam:*)
177+
if (action.endsWith(":*")) {
178+
String service = action.substring(0, action.length() - 2);
179+
switch (service) {
180+
case "storage":
181+
return "s3:*";
182+
case "compute":
183+
return "ec2:*";
184+
case "iam":
185+
return "iam:*";
186+
default:
187+
throw new SubstrateSdkException(
188+
"Unknown substrate-neutral service for wildcard action: " + action + ". " +
189+
"Supported wildcard services: storage:*, compute:*, iam:*"
190+
);
191+
}
192+
}
193+
194+
// Handle specific actions
195+
String awsAction = ACTION_MAPPINGS.get(action);
196+
if (awsAction == null) {
197+
throw new SubstrateSdkException(
198+
"Unknown substrate-neutral action: " + action + ". " +
199+
"Supported actions: " + String.join(", ", ACTION_MAPPINGS.keySet()) +
200+
", or wildcard actions: storage:*, compute:*, iam:*"
201+
);
202+
}
203+
return awsAction;
204+
}
205+
206+
/**
207+
* Translates substrate-neutral resource URI to AWS ARN.
208+
*
209+
* @param resource the substrate-neutral resource URI
210+
* @return AWS ARN
211+
* @throws SubstrateSdkException if resource format is invalid
212+
*/
213+
private static String translateResource(String resource) {
214+
// Handle wildcard
215+
if ("*".equals(resource)) {
216+
return "*";
217+
}
218+
219+
// Handle storage:// URIs
220+
if (resource.startsWith("storage://")) {
221+
String path = resource.substring("storage://".length());
222+
return "arn:aws:s3:::" + path;
223+
}
224+
225+
// If already an ARN, return as-is
226+
if (resource.startsWith("arn:")) {
227+
return resource;
228+
}
229+
230+
throw new SubstrateSdkException(
231+
"Unknown resource format: " + resource + ". " +
232+
"Supported formats: storage://bucket/key, arn:aws:..., or *"
233+
);
234+
}
235+
236+
/**
237+
* Translates substrate-neutral principals to AWS principal format.
238+
*
239+
* @param principals list of substrate-neutral principals
240+
* @return AWS principal object
241+
*/
242+
private static Map<String, Object> translatePrincipals(List<String> principals) {
243+
Map<String, Object> principalMap = new LinkedHashMap<>();
244+
List<String> awsPrincipals = new ArrayList<>();
245+
List<String> servicePrincipals = new ArrayList<>();
246+
247+
for (String principal : principals) {
248+
if (principal.endsWith(SERVICE_PRINCIPAL_SUFFIX)) {
249+
servicePrincipals.add(principal);
250+
} else {
251+
awsPrincipals.add(principal);
252+
}
253+
}
254+
255+
if (!awsPrincipals.isEmpty()) {
256+
principalMap.put("AWS", awsPrincipals.size() == 1 ? awsPrincipals.get(0) : awsPrincipals);
257+
}
258+
if (!servicePrincipals.isEmpty()) {
259+
principalMap.put("Service", servicePrincipals.size() == 1 ? servicePrincipals.get(0) : servicePrincipals);
260+
}
261+
262+
return principalMap;
263+
}
264+
265+
/**
266+
* Translates substrate-neutral conditions to AWS condition format.
267+
*
268+
* @param conditions substrate-neutral conditions
269+
* @return AWS conditions
270+
* @throws SubstrateSdkException if condition operator is unsupported
271+
*/
272+
private static Map<String, Map<String, Object>> translateConditions(
273+
Map<String, Map<String, Object>> conditions) {
274+
Map<String, Map<String, Object>> awsConditions = new LinkedHashMap<>();
275+
276+
for (Map.Entry<String, Map<String, Object>> entry : conditions.entrySet()) {
277+
String operator = entry.getKey();
278+
String awsOperator = CONDITION_MAPPINGS.get(operator);
279+
280+
if (awsOperator == null) {
281+
throw new SubstrateSdkException(
282+
"Unsupported condition operator: " + operator + ". " +
283+
"Supported operators: " + String.join(", ", CONDITION_MAPPINGS.keySet())
284+
);
285+
}
286+
287+
awsConditions.put(awsOperator, entry.getValue());
288+
}
289+
290+
return awsConditions;
291+
}
292+
}

iam/iam-aws/src/test/java/com/salesforce/multicloudj/iam/aws/AwsIamIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public String getTestPolicyEffect() {
114114

115115
@Override
116116
public List<String> getTestPolicyActions() {
117-
return List.of("s3:GetObject", "s3:PutObject");
117+
return List.of("storage:GetObject", "storage:PutObject");
118118
}
119119

120120
@Override

0 commit comments

Comments
 (0)