diff --git a/CedarJava/.gitignore b/CedarJava/.gitignore index c5959e38..e23adf0b 100644 --- a/CedarJava/.gitignore +++ b/CedarJava/.gitignore @@ -13,6 +13,6 @@ .factorypath .project .settings/ - +bin/ # Ignore changes to gradle.properties because we enter passwords here for releases /gradle.properties diff --git a/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java b/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java index 898b3525..6900fc21 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java +++ b/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java @@ -26,6 +26,7 @@ import com.cedarpolicy.serializer.SchemaSerializer; import com.cedarpolicy.serializer.ValueDeserializer; import com.cedarpolicy.serializer.ValueSerializer; +import com.cedarpolicy.serializer.EntityDeserializer; import com.cedarpolicy.value.Value; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; @@ -33,7 +34,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -final class CedarJson { +public final class CedarJson { private static final ObjectMapper OBJECT_MAPPER = createObjectMapper(); private CedarJson() { @@ -41,7 +42,7 @@ private CedarJson() { } public static ObjectMapper objectMapper() { - return OBJECT_MAPPER; + return OBJECT_MAPPER.copy(); } public static ObjectWriter objectWriter() { @@ -62,6 +63,7 @@ private static ObjectMapper createObjectMapper() { module.addSerializer(PolicySet.class, new PolicySetSerializer()); module.addSerializer(Value.class, new ValueSerializer()); module.addDeserializer(Value.class, new ValueDeserializer()); + module.addDeserializer(Entity.class, new EntityDeserializer()); mapper.registerModule(module); mapper.registerModule(new Jdk8Module()); diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/entity/Entity.java b/CedarJava/src/main/java/com/cedarpolicy/model/entity/Entity.java index 8b5e348b..ae05e0ae 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/model/entity/Entity.java +++ b/CedarJava/src/main/java/com/cedarpolicy/model/entity/Entity.java @@ -18,15 +18,20 @@ import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.Value; +import static com.cedarpolicy.CedarJson.objectReader; +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.io.IOException; +import java.nio.file.Files; import java.util.*; import java.util.stream.Collectors; +import java.nio.file.Path; /** - * An entity is the kind of object about which authorization decisions are made; principals, - * actions, and resources are all a kind of entity. Each entity is defined by its entity type, a - * unique identifier (UID), zero or more attributes mapped to values, zero or more parent - * entities, and zero or more tags. + * An entity is the kind of object about which authorization decisions are made; principals, actions, and resources are + * all a kind of entity. Each entity is defined by its entity type, a unique identifier (UID), zero or more attributes + * mapped to values, zero or more parent entities, and zero or more tags. */ public class Entity { private final EntityUID euid; @@ -52,7 +57,7 @@ public Entity(EntityUID uid) { /** * Create an entity from an EntityUID and a set of parent EntityUIDs. It will have no attributes or tags. * - * @param uid EUID of the Entity. + * @param uid EUID of the Entity. * @param parentsEUIDs Set of parent entities' EUIDs. */ public Entity(EntityUID uid, Set parentsEUIDs) { @@ -62,8 +67,8 @@ public Entity(EntityUID uid, Set parentsEUIDs) { /** * Create an entity from an EntityUIDs, a map of attributes, and a set of parent EntityUIDs. * - * @param uid EUID of the Entity. - * @param attributes Key/Value map of attributes. + * @param uid EUID of the Entity. + * @param attributes Key/Value map of attributes. * @param parentsEUIDs Set of parent entities' EUIDs. */ public Entity(EntityUID uid, Map attributes, Set parentsEUIDs) { @@ -73,10 +78,10 @@ public Entity(EntityUID uid, Map attributes, Set paren /** * Create an entity from an EntityUIDs, a map of attributes, a set of parent EntityUIDs, and a map of tags. * - * @param uid EUID of the Entity. - * @param attributes Key/Value map of attributes. + * @param uid EUID of the Entity. + * @param attributes Key/Value map of attributes. * @param parentsEUIDs Set of parent entities' EUIDs. - * @param tags Key/Value map of tags. + * @param tags Key/Value map of tags. */ public Entity(EntityUID uid, Map attributes, Set parentsEUIDs, Map tags) { this.attrs = new HashMap<>(attributes); @@ -87,8 +92,9 @@ public Entity(EntityUID uid, Map attributes, Set paren /** * Get the value for the given attribute, or null if not present. - * + * * @param attribute Attribute key + * * @return Attribute value for the given key or null if not present * @throws IllegalArgumentException if attribute is null */ @@ -103,32 +109,26 @@ public Value getAttr(String attribute) { public String toString() { String parentStr = ""; if (!parentsEUIDs.isEmpty()) { - List parentStrs = new ArrayList(parentsEUIDs.stream() - .map(euid -> euid.toString()).collect(Collectors.toList())); + List parentStrs = new ArrayList( + parentsEUIDs.stream().map(euid -> euid.toString()).collect(Collectors.toList())); parentStr = "\n\tparents:\n\t\t" + String.join("\n\t\t", parentStrs); } String attributeStr = ""; if (!attrs.isEmpty()) { - attributeStr = - "\n\tattrs:\n\t\t" - + attrs.entrySet().stream() - .map(e -> e.getKey() + ": " + e.getValue()) - .collect(Collectors.joining("\n\t\t")); + attributeStr = "\n\tattrs:\n\t\t" + attrs.entrySet().stream().map(e -> e.getKey() + ": " + e.getValue()) + .collect(Collectors.joining("\n\t\t")); } String tagsStr = ""; if (!tags.isEmpty()) { - tagsStr = - "\n\ttags:\n\t\t" - + tags.entrySet().stream() - .map(e -> e.getKey() + ": " + e.getValue()) - .collect(Collectors.joining("\n\t\t")); + tagsStr = "\n\ttags:\n\t\t" + tags.entrySet().stream().map(e -> e.getKey() + ": " + e.getValue()) + .collect(Collectors.joining("\n\t\t")); } return euid.toString() + parentStr + attributeStr + tagsStr; } - /** * Get the entity uid + * * @return Entity UID */ public EntityUID getEUID() { @@ -137,6 +137,7 @@ public EntityUID getEUID() { /** * Get this Entity's parents + * * @return the set of parent EntityUIDs */ public Set getParents() { @@ -145,9 +146,36 @@ public Set getParents() { /** * Get this Entity's tags + * * @return the map of tags */ public Map getTags() { return tags; } + + /** + * Parse Entity from a JSON string + * + * @param jsonString The JSON string representation of an Entity + * + * @return Entity object parsed from the JSON string + * @throws JsonProcessingException if the JSON string cannot be parsed into an Entity + */ + public static Entity parse(String jsonString) throws JsonProcessingException { + return objectReader().forType(Entity.class).readValue(jsonString); + } + + /** + * Parse Entity from a file containing JSON representation of an Entity + * + * @param filePath Path to the file containing Entity JSON + * + * @return Entity object parsed from the file contents + * @throws IOException if there is an error reading the file + * @throws JsonProcessingException if the file contents cannot be parsed into an Entity + */ + public static Entity parse(Path filePath) throws IOException, JsonProcessingException { + String jsonString = Files.readString(filePath); + return parse(jsonString); + } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/EntityDeserializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/EntityDeserializer.java new file mode 100644 index 00000000..2fb1156b --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/EntityDeserializer.java @@ -0,0 +1,157 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cedarpolicy.serializer; + +import com.cedarpolicy.model.exception.InvalidValueDeserializationException; +import com.cedarpolicy.value.Value; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.model.entity.Entity; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.Map; +import java.util.HashMap; +import java.util.Set; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Deserialize Json to Entity. + */ +public class EntityDeserializer extends JsonDeserializer { + + /** + * Deserializes a JSON input into an Entity object. + * + * @param parser The JsonParser providing the JSON input to deserialize + * @param context The deserialization context + * + * @return An Entity object constructed from the JSON input + * @throws IOException If there is an error reading from the JsonParser + * @throws InvalidValueDeserializationException If the JSON input is invalid or missing required fields + */ + @Override + public Entity deserialize(JsonParser parser, DeserializationContext context) + throws IOException, InvalidValueDeserializationException { + final JsonNode node = parser.getCodec().readTree(parser); + final ObjectMapper mapper = (ObjectMapper) parser.getCodec(); + + EntityUID euid; + if (node.has("uid")) { + JsonNode uidNode = node.get("uid"); + euid = parseEntityUID(parser, uidNode); + } else { + String msg = "\"uid\" not found"; + throw new InvalidValueDeserializationException(parser, msg, node.asToken(), Entity.class); + } + + Map attrs; + if (node.has("attrs")) { + JsonNode attrsNode = node.get("attrs"); + if (attrsNode.isObject()) { + attrs = parseValueMap(mapper, attrsNode); + } else { + String msg = "\"attrs\" must be a JSON object"; + throw new InvalidValueDeserializationException(parser, msg, attrsNode.asToken(), Entity.class); + } + } else { + String msg = "\"attrs\" not found"; + throw new InvalidValueDeserializationException(parser, msg, node.asToken(), Entity.class); + } + + Set parentEUIDs; + if (node.has("parents")) { + JsonNode parentsNode = node.get("parents"); + if (parentsNode.isArray()) { + parentEUIDs = StreamSupport.stream(parentsNode.spliterator(), false).map(parentNode -> { + try { + return parseEntityUID(parser, parentNode); + } catch (InvalidValueDeserializationException e) { + throw new RuntimeException(e); + } + }).collect(Collectors.toSet()); + } else { + String msg = "\"parents\" field must be a JSON array"; + throw new InvalidValueDeserializationException(parser, msg, parentsNode.asToken(), Entity.class); + } + } else { + String msg = "\"parents\" not found"; + throw new InvalidValueDeserializationException(parser, msg, node.asToken(), Entity.class); + } + + Map tags = new HashMap<>(); + if (node.has("tags")) { + JsonNode tagsNode = node.get("tags"); + if (tagsNode.isObject()) { + tags.putAll(parseValueMap(mapper, tagsNode)); + } else { + String msg = "\"tags\" must be a JSON object"; + throw new InvalidValueDeserializationException(parser, msg, tagsNode.asToken(), Entity.class); + } + } + return new Entity(euid, attrs, parentEUIDs, tags); + } + + /** + * Parses a JSON node into an EntityUID object. + * + * @param parser The JsonParser used for error reporting + * @param entityUIDJson The JsonNode containing the entity UID data to parse. Must have "type" and "id" fields. + * + * @return An EntityUID object constructed from the JSON data + * @throws InvalidValueDeserializationException if the required fields are missing or invalid + */ + private EntityUID parseEntityUID(JsonParser parser, JsonNode entityUIDJson) + throws InvalidValueDeserializationException { + if (entityUIDJson.has("type") && entityUIDJson.has("id")) { + JsonEUID jsonEuid = new JsonEUID(entityUIDJson.get("type").asText(), entityUIDJson.get("id").asText()); + return EntityUID.parseFromJson(jsonEuid).get(); + } else { + String msg = "\"type\" or \"id\" not found"; + throw new InvalidValueDeserializationException(parser, msg, entityUIDJson.asToken(), Entity.class); + } + } + + /** + * Parses a JSON node containing key-value pairs into a Map of String to Value objects. + * + * @param mapper The ObjectMapper used to convert JSON nodes to Value objects + * @param valueMapJson The JsonNode containing the key-value pairs to parse + * + * @return A Map where keys are Strings and values are Value objects + * @throws RuntimeException if there is an error converting a JSON node to a Value + */ + private Map parseValueMap(ObjectMapper mapper, JsonNode valueMapJson) { + Map valueMap = StreamSupport + .stream(Spliterators.spliteratorUnknownSize(valueMapJson.fields(), Spliterator.ORDERED), false) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> { + try { + return mapper.treeToValue(entry.getValue(), Value.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + })); + return valueMap; + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/EntityTests.java b/CedarJava/src/test/java/com/cedarpolicy/EntityTests.java index 84a0bb2a..2c872635 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/EntityTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/EntityTests.java @@ -18,16 +18,21 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.HashMap; -import java.util.HashSet; - import static org.junit.jupiter.api.Assertions.assertEquals; import com.cedarpolicy.value.*; import com.cedarpolicy.model.entity.Entity; +import static com.cedarpolicy.CedarJson.objectWriter; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; public class EntityTests { + private static final String TEST_RESOURCES_DIR = "src/test/resources/"; @Test public void getAttrTests() { @@ -81,4 +86,116 @@ public void newWithoutAttributesTests() { // Test the Entity's parents assertEquals(principal.getParents(), parents); } + + public void givenValidJSONStringParseReturns() throws JsonProcessingException { + String validEntityJson = """ + {"uid":{"type":"Photo","id":"pic01"}, + "attrs":{ + "dummyIP": {"__extn":{"fn":"ip","arg":"192.168.1.100"}}, + "dummyUser": {"__entity":{"type":"User","id":"Alice"}}, + "nestedAttr":{ + "managerName": "Someone", + "skip":{ + "name": "something", + "who": {"__entity":{"type":"User","id":"Alice"}} + } + } + }, + "parents":[{"type":"Photo","id":"pic01"}], + "tags": { + "dummyTagIP": {"__extn":{"fn":"ip","arg":"192.168.1.100"}}, + "dummyTagUser": {"__entity":{"type":"User::Tag","id":"Alice"}}, + "nestedTagAttr":{ + "managerTagName": "Someone", + "skipTag":{ + "name": "somethingTag", + "who": {"__entity":{"type":"UserFromStringTag","id":"AliceTag"}} + } + } + } + } + """; + + Entity entity = Entity.parse(validEntityJson); + String jsonRepresentation = objectWriter().writeValueAsString(entity); + String expectedRepresentation = "{\"uid\":{\"type\":\"Photo\",\"id\":\"pic01\"}," + + "\"attrs\":{\"dummyIP\":{\"__extn\":{\"fn\":\"ip\",\"arg\":\"192.168.1.100\"}}," + + "\"nestedAttr\":{\"skip\":{\"name\":\"something\",\"who\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User\"}}}," + + "\"managerName\":\"Someone\"},\"dummyUser\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User\"}}}," + + "\"parents\":[{\"type\":\"Photo\",\"id\":\"pic01\"}]," + + "\"tags\":{\"dummyTagIP\":{\"__extn\":{\"fn\":\"ip\",\"arg\":\"192.168.1.100\"}}," + + "\"nestedTagAttr\":{\"skipTag\":{\"name\":\"somethingTag\"," + + "\"who\":{\"__entity\":{\"id\":\"AliceTag\",\"type\":\"UserFromStringTag\"}}},\"managerTagName\":\"Someone\"}," + + "\"dummyTagUser\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User::Tag\"}}}}"; + + assertEquals(expectedRepresentation, jsonRepresentation); + + validEntityJson = """ + {"uid":{"type":"Photo","id":"pic01"}, + "attrs":{}, + "parents":[]} + """; + entity = Entity.parse(validEntityJson); + jsonRepresentation = objectWriter().writeValueAsString(entity); + expectedRepresentation = "{\"uid\":{\"type\":\"Photo\",\"id\":\"pic01\"}," + + "\"attrs\":{}," + + "\"parents\":[]," + + "\"tags\":{}}"; + + assertEquals(expectedRepresentation, jsonRepresentation); + } + + @Test + public void givenInvalidJSONStringParseThrows() throws JsonProcessingException { + String invalidEntityJson = """ + {"uid":{"type":"Photo","id":"pic01"}} + """; + + assertThrows(JsonProcessingException.class, () -> { + Entity.parse(invalidEntityJson); + }); + + String invalidEntityJson2 = """ + {"uid":{"type":"Photo","id":"pic01"}}, + "parents":{}, + "attrs":[] + """; + + assertThrows(JsonProcessingException.class, () -> { + Entity.parse(invalidEntityJson2); + }); + + String invalidEntityJson3 = """ + {"uid":{"type":"Photo","id":"pic01"}}, + "parents":[], + "attrs":{}, + "tags":[] + """; + + assertThrows(JsonProcessingException.class, () -> { + Entity.parse(invalidEntityJson3); + }); + } + + public void givenValidJSONFileParseReturns() throws JsonProcessingException, IOException { + Entity entity = Entity.parse(Path.of(TEST_RESOURCES_DIR + "valid_entity.json")); + String jsonRepresentation = objectWriter().writeValueAsString(entity); + String expectedRepresentation = "{\"uid\":{\"type\":\"Photo\",\"id\":\"pic01\"}," + + "\"attrs\":{\"dummyIP\":{\"__extn\":{\"fn\":\"ip\",\"arg\":\"192.168.1.100\"}}," + + "\"nestedAttr\":{\"skip\":{\"name\":\"something\",\"who\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User\"}}}," + + "\"managerName\":\"Someone\"},\"dummyUser\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User\"}}}," + + "\"parents\":[{\"type\":\"Photo\",\"id\":\"pic01\"}]," + + "\"tags\":{\"dummyTagIP\":{\"__extn\":{\"fn\":\"ip\",\"arg\":\"192.168.1.100\"}}," + + "\"nestedTagAttr\":{\"skipTag\":{\"name\":\"somethingTag\"," + + "\"who\":{\"__entity\":{\"id\":\"AliceTag\",\"type\":\"UserFromStringTag\"}}},\"managerTagName\":\"Someone\"}," + + "\"dummyTagUser\":{\"__entity\":{\"id\":\"Alice\",\"type\":\"User::Tag\"}}}}"; + + assertEquals(expectedRepresentation, jsonRepresentation); + } + + public void givenInvalidJSONFileParseThrows() throws JsonProcessingException, IOException { + assertThrows(JsonProcessingException.class, () -> { + Entity.parse(Path.of(TEST_RESOURCES_DIR + "invalid_entity.json")); + }); + } } diff --git a/CedarJava/src/test/resources/invalid_entity.json b/CedarJava/src/test/resources/invalid_entity.json new file mode 100644 index 00000000..d37cd9f8 --- /dev/null +++ b/CedarJava/src/test/resources/invalid_entity.json @@ -0,0 +1 @@ +{"uid":{"type":"Photo","id":"pic01"}} diff --git a/CedarJava/src/test/resources/valid_entity.json b/CedarJava/src/test/resources/valid_entity.json new file mode 100644 index 00000000..d35f2ca0 --- /dev/null +++ b/CedarJava/src/test/resources/valid_entity.json @@ -0,0 +1,25 @@ +{"uid":{"type":"Photo","id":"pic01"}, +"attrs":{ + "dummyIP": {"__extn":{"fn":"ip","arg":"192.168.1.100"}}, + "dummyUser": {"__entity":{"type":"User","id":"Alice"}}, + "nestedAttr":{ + "managerName": "Someone", + "skip":{ + "name": "something", + "who": {"__entity":{"type":"User","id":"Alice"}} + } + } + }, +"parents":[{"type":"Photo","id":"pic01"}], +"tags": { + "dummyTagIP": {"__extn":{"fn":"ip","arg":"192.168.1.100"}}, + "dummyTagUser": {"__entity":{"type":"User::Tag","id":"Alice"}}, + "nestedTagAttr":{ + "managerTagName": "Someone", + "skipTag":{ + "name": "somethingTag", + "who": {"__entity":{"type":"UserFromStringTag","id":"AliceTag"}} + } + } + } +}