diff --git a/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java index b5a9a35..681cbae 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/EntityValidationTests.java @@ -35,9 +35,13 @@ import com.cedarpolicy.model.schema.Schema; import com.cedarpolicy.pbt.EntityGen; import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.PrimBool; import com.cedarpolicy.value.PrimString; +import java.util.HashMap; +import java.util.HashSet; + /** * Tests for entity validator */ @@ -193,6 +197,147 @@ public void testEntityWithUnknownTagWithCedarSchema() throws AuthException { "Expected to match regex but was: '%s'".formatted(errMsg)); } + /** + * Test that valid enum entities are accepted. + */ + @Test + public void testValidEnumEntities() throws AuthException { + // Create valid entities using enum types + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName taskType = EntityTypeName.parse("Task").get(); + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + + Entity user = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + Entity task = new Entity(taskType.of("task1"), new HashMap<>() { + { + put("owner", user.getEUID()); + put("name", new PrimString("Complete project")); + put("status", new EntityUID(colorType, "Red")); + } + }, new HashSet<>()); + + EntityValidationRequest request = new EntityValidationRequest(ENUM_SCHEMA, List.of(user, task)); + engine.validateEntities(request); + } + + /** + * Test that enum entities with invalid enum values are rejected. + */ + @Test + public void testEnumEntitiesWithInvalidValues() throws AuthException { + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName taskType = EntityTypeName.parse("Task").get(); + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + + Entity user = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + // Create task with invalid enum value "Purple" (not in Color enum) + Entity task = new Entity(taskType.of("task1"), new HashMap<>() { + { + put("owner", user.getEUID()); + put("name", new PrimString("Complete project")); + put("status", new EntityUID(colorType, "Purple")); // Invalid enum value + } + }, new HashSet<>()); + + EntityValidationRequest request = new EntityValidationRequest(ENUM_SCHEMA, List.of(user, task)); + + BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + + String errMsg = exception.getErrors().get(0); + assertTrue(errMsg.contains("Purple") || errMsg.contains("Color"), + "Expected error about invalid enum value but was: '%s'".formatted(errMsg)); + } + + /** + * Test that enum entities cannot have attributes. + */ + @Test + public void testEnumEntitiesCannotHaveAttributes() throws AuthException { + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + + // Try to create enum entity with attributes (should fail) + Entity enumEntity = new Entity(colorType.of("Red"), new HashMap<>() { + { + put("shade", new PrimString("Dark")); // Enum entities shouldn't have attributes + } + }, new HashSet<>()); + + EntityValidationRequest request = new EntityValidationRequest(ENUM_SCHEMA, List.of(enumEntity)); + + BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + + String errMsg = exception.getErrors().get(0); + assertTrue(errMsg.contains("attribute") && (errMsg.contains("Color") || errMsg.contains("Red")), + "Expected error about enum entity having attributes but was: '%s'".formatted(errMsg)); + } + + /** + * Test that enum entities cannot have parents. + */ + @Test + public void testEnumEntitiesCannotHaveParents() throws AuthException { + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + EntityTypeName userType = EntityTypeName.parse("User").get(); + + Entity user = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + // Try to create enum entity with parent (should fail) + Entity enumEntity = new Entity(colorType.of("Red"), new HashMap<>(), new HashSet<>() { + { + add(user.getEUID()); // Enum entities shouldn't have parents + } + }); + + EntityValidationRequest request = new EntityValidationRequest(ENUM_SCHEMA, List.of(user, enumEntity)); + + BadRequestException exception = assertThrows(BadRequestException.class, () -> engine.validateEntities(request)); + + String errMsg = exception.getErrors().get(0); + assertTrue(errMsg.contains("parent") || errMsg.contains("ancestor") || errMsg.contains("Color"), + "Expected error about enum entity having parents but was: '%s'".formatted(errMsg)); + } + + /** + * Test enum entity validation with Cedar schema format. + */ + @Test + public void testEnumEntitiesWithCedarSchema() throws AuthException { + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName taskType = EntityTypeName.parse("Task").get(); + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + + Entity user = new Entity(userType.of("bob"), new HashMap<>() { + { + put("name", new PrimString("Bob")); + } + }, new HashSet<>()); + + Entity task = new Entity(taskType.of("task2"), new HashMap<>() { + { + put("owner", user.getEUID()); + put("name", new PrimString("Review code")); + put("status", new EntityUID(colorType, "Green")); + } + }, new HashSet<>()); + + EntityValidationRequest cedarRequest = new EntityValidationRequest(ENUM_SCHEMA_CEDAR, List.of(user, task)); + engine.validateEntities(cedarRequest); + } + @BeforeAll public static void setUp() { @@ -204,4 +349,6 @@ public static void setUp() { private static final Schema ROLE_SCHEMA = loadSchemaResource("/role_schema.json"); private static final Schema ROLE_SCHEMA_CEDAR = loadCedarSchemaResource("/role_schema.cedarschema"); + private static final Schema ENUM_SCHEMA = loadSchemaResource("/enum_schema.json"); + private static final Schema ENUM_SCHEMA_CEDAR = loadCedarSchemaResource("/enum_schema.cedarschema"); } diff --git a/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java b/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java index 1883e9a..c3ab0e3 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/SchemaTests.java @@ -18,6 +18,8 @@ import java.util.Optional; +import static com.cedarpolicy.TestUtil.loadSchemaResource; +import static com.cedarpolicy.TestUtil.loadCedarSchemaResource; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -235,4 +237,88 @@ void testMalformedSchema() { assertThrows(InternalException.class, malformedSchema::toJsonFormat); } } + + @Nested + @DisplayName("Enum Schema Tests") + class EnumSchemaTests { + + @Test + void testParseJsonEnumSchema() { + assertDoesNotThrow(() -> { + Schema enumSchema = loadSchemaResource("/enum_schema.json"); + assertNotNull(enumSchema, "Enum schema should not be null"); + }); + } + + @Test + @DisplayName("Should parse Cedar schema with enum entities") + void testParseCedarEnumSchema() { + assertDoesNotThrow(() -> { + Schema enumSchema = loadCedarSchemaResource("/enum_schema.cedarschema"); + assertNotNull(enumSchema, "Enum schema should not be null"); + }); + } + + @Test + void testRejectEmptyEnums() { + // Test Cedar format empty enum + assertThrows(Exception.class, () -> { + Schema.parse(JsonOrCedar.Cedar, "entity Color enum [];"); + }); + + // Test JSON format empty enum + assertThrows(Exception.class, () -> { + Schema.parse(JsonOrCedar.Json, """ + { + "": { + "entityTypes": { + "Color": { + "enum": [] + } + }, + "actions": {} + } + } + """); + }); + } + + @Test + void testEnumSchemaFormatConversion() throws Exception { + // Test Cedar to JSON conversion + Schema cedarEnumSchema = Schema.parse(JsonOrCedar.Cedar, """ + entity Color enum ["Red", "Blue", "Green"]; + entity User; + action view appliesTo { principal: [User], resource: [User] }; + """); + + JsonNode jsonResult = cedarEnumSchema.toJsonFormat(); + assertNotNull(jsonResult, "JSON conversion result should not be null"); + + // Test JSON to Cedar conversion + String jsonEnumSchema = """ + { + "": { + "entityTypes": { + "Color": { + "enum": ["Red", "Blue", "Green"] + }, + "User": {} + }, + "actions": { + "view": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["User"] + } + } + } + } + } + """; + Schema jsonSchemaObj = Schema.parse(JsonOrCedar.Json, jsonEnumSchema); + String cedarResult = jsonSchemaObj.toCedarFormat(); + assertNotNull(cedarResult, "Cedar conversion result should not be null"); + } + } } diff --git a/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java b/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java index 2478d40..d3943c7 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/ValidationTests.java @@ -222,6 +222,26 @@ public void validateLevelPolicyFailsWhenExpected() { thenIsNotValid(levelResponse); } + /** Test enum entity validation with valid enum values. */ + @Test + public void givenEnumSchemaAndValidEnumUsageReturnsValid() { + givenSchema(ENUM_SCHEMA); + givenPolicy("policy0", "permit(" + " principal == User::\"alice\"," + " action == Action::\"UpdateTask\"," + + " resource == Task::\"task1\"" + ") when {" + " resource.status == Color::\"Red\"" + "};"); + ValidationResponse response = whenValidated(); + thenIsValid(response); + } + + /** Test enum entity validation with invalid enum values. */ + @Test + public void givenEnumSchemaAndInvalidEnumValueReturnsInvalid() { + givenSchema(ENUM_SCHEMA); + givenPolicy("policy0", "permit(" + " principal == User::\"alice\"," + " action == Action::\"UpdateTask\"," + + " resource == Task::\"task1\"" + ") when {" + " resource.status != Color::\"Purple\"" + "};"); + ValidationResponse response = whenValidated(); + thenIsNotValid(response); + } + private void givenSchema(Schema testSchema) { this.schema = testSchema; } @@ -285,4 +305,6 @@ private void reset() { private static final Schema PHOTOFLASH_SCHEMA = loadSchemaResource("/photoflash_schema.json"); private static final Schema LIBRARY_SCHEMA = loadSchemaResource("/library_schema.json"); private static final Schema LEVEL_SCHEMA = loadSchemaResource("/level_schema.json"); + private static final Schema ENUM_SCHEMA = loadSchemaResource("/enum_schema.json"); + } diff --git a/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java index 04dd86b..dfc46fb 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java @@ -30,6 +30,7 @@ import com.cedarpolicy.model.policy.Policy; import com.cedarpolicy.model.policy.PolicySet; import com.cedarpolicy.model.policy.TemplateLink; +import com.cedarpolicy.model.schema.Schema; import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; import com.cedarpolicy.value.Duration; @@ -895,4 +896,242 @@ public void testSchemaParsingAllow() { true); assertAllowed(request, policySet, entities); } + + private static final Schema ENUM_SCHEMA = loadSchemaResource("/enum_schema.json"); + + /** + * Build entities for enum authorization tests. + */ + private Set buildEntitiesForEnumTests() { + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName taskType = EntityTypeName.parse("Task").get(); + EntityTypeName colorType = EntityTypeName.parse("Color").get(); + EntityTypeName actionType = EntityTypeName.parse("Action").get(); + EntityTypeName applicationType = EntityTypeName.parse("Application").get(); + + Set entities = new HashSet<>(); + + // Users + Entity alice = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + Entity bob = new Entity(userType.of("bob"), new HashMap<>() { + { + put("name", new PrimString("Bob")); + } + }, new HashSet<>()); + + // Tasks + Entity task1 = new Entity(taskType.of("task1"), new HashMap<>() { + { + put("owner", alice.getEUID()); + put("name", new PrimString("Complete project")); + put("status", new EntityUID(colorType, "Red")); + } + }, new HashSet<>()); + + Entity task2 = new Entity(taskType.of("task2"), new HashMap<>() { + { + put("owner", bob.getEUID()); + put("name", new PrimString("Review code")); + put("status", new EntityUID(colorType, "Green")); + } + }, new HashSet<>()); + + // Actions + Entity updateTaskAction = new Entity(actionType.of("UpdateTask"), new HashMap<>(), new HashSet<>()); + Entity createListAction = new Entity(actionType.of("CreateList"), new HashMap<>(), new HashSet<>()); + + // Application enum entity + Entity tinyTodoApp = new Entity(applicationType.of("TinyTodo"), new HashMap<>(), new HashSet<>()); + + entities.add(alice); + entities.add(bob); + entities.add(task1); + entities.add(task2); + entities.add(updateTaskAction); + entities.add(createListAction); + entities.add(tinyTodoApp); + + return entities; + } + + /** + * Test authorization requests which results in Deny due to enum type + */ + @Test + public void testValidEnumAuthorizationDeny() { + EntityUID principal = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + EntityUID action = new EntityUID(EntityTypeName.parse("Action").get(), "UpdateTask"); + EntityUID resource = new EntityUID(EntityTypeName.parse("Task").get(), "task2"); // task2 has Green status + + Set entities = buildEntitiesForEnumTests(); + + var policies = new HashSet(); + policies.add(new Policy(""" + permit( + principal, + action == Action::"UpdateTask", + resource + ) when { + resource.status != Color::"Green" + }; + """, "notGreenPolicy")); + + var policySet = new PolicySet(policies); + AuthorizationRequest request = new AuthorizationRequest(principal, action, resource, new HashMap<>()); + + assertNotAllowed(request, policySet, entities); + } + + /** + * Test authorization requests which results in Permit due to enum type + */ + @Test + public void testValidEnumAuthorizationAllow() { + EntityUID principal = new EntityUID(EntityTypeName.parse("User").get(), "bob"); + EntityUID action = new EntityUID(EntityTypeName.parse("Action").get(), "UpdateTask"); + EntityUID resource = new EntityUID(EntityTypeName.parse("Task").get(), "task2"); // task2 has Green status + + Set entities = buildEntitiesForEnumTests(); + + // Policy: allow if status is exactly Green + var policies = new HashSet(); + policies.add(new Policy(""" + permit( + principal == User::"bob", + action == Action::"UpdateTask", + resource + ) when { + resource.status == Color::"Green" + }; + """, "greenPolicy")); + + var policySet = new PolicySet(policies); + AuthorizationRequest request = new AuthorizationRequest(principal, action, resource, new HashMap<>()); + + assertAllowed(request, policySet, entities); + } + + /** + * Test authorization requests where policy references multiple enums + */ + @Test + public void testEnumAuthorizationMultipleValues() { + EntityUID principal = new EntityUID(EntityTypeName.parse("User").get(), "bob"); + EntityUID action = new EntityUID(EntityTypeName.parse("Action").get(), "UpdateTask"); + EntityUID resource = new EntityUID(EntityTypeName.parse("Task").get(), "task2"); // Green status + + Set entities = buildEntitiesForEnumTests(); + + // Policy: allow if status is Blue OR Green + var policies = new HashSet(); + policies.add(new Policy(""" + permit( + principal, + action == Action::"UpdateTask", + resource + ) when { + resource.status == Color::"Blue" || + resource.status == Color::"Green" + }; + """, "multiEnumPolicy")); + + var policySet = new PolicySet(policies); + AuthorizationRequest request = new AuthorizationRequest(principal, action, resource, new HashMap<>()); + + assertAllowed(request, policySet, entities); + } + + /** + * Test authorization with schema validation - positive case with valid enum entities. + */ + @Test + public void testValidEnumAuthorizationWithValidation() { + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName actionType = EntityTypeName.parse("Action").get(); + EntityTypeName applicationType = EntityTypeName.parse("Application").get(); + + // Create valid entities + Entity principalEntity = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + Entity actionEntity = new Entity(actionType.of("CreateList"), new HashMap<>(), new HashSet<>()); + Entity resourceEntity = new Entity(applicationType.of("TinyTodo"), new HashMap<>(), new HashSet<>()); + + Set entities = new HashSet<>(); + entities.add(principalEntity); + entities.add(actionEntity); + entities.add(resourceEntity); + + var policies = new HashSet(); + policies.add(new Policy(""" + permit( + principal, + action == Action::"CreateList", + resource == Application::"TinyTodo" + ); + """, "validEnumPolicy")); + + var policySet = new PolicySet(policies); + + // Create authorization request with schema validation enabled + AuthorizationRequest request = new AuthorizationRequest(principalEntity, actionEntity, resourceEntity, + Optional.of(new HashMap<>()), Optional.of(ENUM_SCHEMA), true + ); + + assertAllowed(request, policySet, entities); + } + + /** + * Test authorization with schema validation - negative case with invalid enum entity. + */ + @Test + public void testInvalidEnumAuthorizationWithValidation() { + EntityTypeName userType = EntityTypeName.parse("User").get(); + EntityTypeName actionType = EntityTypeName.parse("Action").get(); + EntityTypeName applicationType = EntityTypeName.parse("Application").get(); + + // Create entities with invalid enum value + Entity principalEntity = new Entity(userType.of("alice"), new HashMap<>() { + { + put("name", new PrimString("Alice")); + } + }, new HashSet<>()); + + Entity actionEntity = new Entity(actionType.of("CreateList"), new HashMap<>(), new HashSet<>()); + // Use INVALID enum value - "InvalidApp" is not in the Application enum + Entity resourceEntity = new Entity(applicationType.of("InvalidApp"), new HashMap<>(), new HashSet<>()); + + Set entities = new HashSet<>(); + entities.add(principalEntity); + entities.add(actionEntity); + entities.add(resourceEntity); + + var policies = new HashSet(); + policies.add(new Policy(""" + permit( + principal, + action == Action::"CreateList", + resource == Application::"InvalidApp" + ); + """, "invalidEnumPolicy")); + + var policySet = new PolicySet(policies); + + // Create authorization request with schema validation enabled + AuthorizationRequest request = new AuthorizationRequest(principalEntity, actionEntity, resourceEntity, + Optional.of(new HashMap<>()), Optional.of(ENUM_SCHEMA), true // Enable request validation - this should + // catch the invalid enum + ); + + // Should return failure response due to invalid enum value when schema validation is enabled + assertFailure(request, policySet, entities); + } } diff --git a/CedarJava/src/test/resources/enum_entities.json b/CedarJava/src/test/resources/enum_entities.json new file mode 100644 index 0000000..4f50438 --- /dev/null +++ b/CedarJava/src/test/resources/enum_entities.json @@ -0,0 +1,34 @@ +[ + { + "uid": {"type": "User", "id": "alice"}, + "attrs": { + "name": "Alice" + }, + "parents": [] + }, + { + "uid": {"type": "User", "id": "bob"}, + "attrs": { + "name": "Bob" + }, + "parents": [] + }, + { + "uid": {"type": "Task", "id": "task1"}, + "attrs": { + "owner": {"type": "User", "id": "alice"}, + "name": "Complete project", + "status": {"type": "Color", "id": "Red"} + }, + "parents": [] + }, + { + "uid": {"type": "Task", "id": "task2"}, + "attrs": { + "owner": {"type": "User", "id": "bob"}, + "name": "Review code", + "status": {"type": "Color", "id": "Green"} + }, + "parents": [] + } +] diff --git a/CedarJava/src/test/resources/enum_policies.cedar b/CedarJava/src/test/resources/enum_policies.cedar new file mode 100644 index 0000000..fff7ebd --- /dev/null +++ b/CedarJava/src/test/resources/enum_policies.cedar @@ -0,0 +1,49 @@ +// Valid enum policy from RFC example +permit( + principal, + action == Action::"UpdateTask", + resource +) +when { + principal == resource.owner && + resource.status != Color::"Red" +}; + +// Valid enum equality comparison +permit( + principal == User::"alice", + action == Action::"UpdateTask", + resource +) +when { + resource.status == Color::"Green" +}; + +// Valid application enum usage +permit( + principal, + action == Action::"CreateList", + resource == Application::"TinyTodo" +); + +// Policy with multiple enum comparisons +permit( + principal, + action == Action::"UpdateTask", + resource +) +when { + resource.status == Color::"Blue" || + resource.status == Color::"Green" +}; + +// Policy testing enum in conditions +forbid( + principal, + action == Action::"UpdateTask", + resource +) +when { + resource.status == Color::"Red" && + principal != resource.owner +}; diff --git a/CedarJava/src/test/resources/enum_schema.cedarschema b/CedarJava/src/test/resources/enum_schema.cedarschema new file mode 100644 index 0000000..520cde8 --- /dev/null +++ b/CedarJava/src/test/resources/enum_schema.cedarschema @@ -0,0 +1,21 @@ +entity User = { + name: String, +}; + +entity Color enum ["Red", "Blue", "Green"]; + +entity Application enum ["TinyTodo"]; + +entity Status enum ["Active", "Inactive", "Pending"]; + +entity Task = { + owner: User, + name: String, + status: Color, +}; + +action UpdateTask + appliesTo { principal: [User], resource: [Task] }; + +action CreateList + appliesTo { principal: [User], resource: [Application] }; diff --git a/CedarJava/src/test/resources/enum_schema.json b/CedarJava/src/test/resources/enum_schema.json new file mode 100644 index 0000000..0bd5f92 --- /dev/null +++ b/CedarJava/src/test/resources/enum_schema.json @@ -0,0 +1,61 @@ +{ + "": { + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "name": { + "type": "String", + "required": true + } + } + } + }, + "Color": { + "enum": ["Red", "Blue", "Green"] + }, + "Application": { + "enum": ["TinyTodo"] + }, + "Status": { + "enum": ["Active", "Inactive", "Pending"] + }, + "Task": { + "shape": { + "type": "Record", + "attributes": { + "owner": { + "type": "Entity", + "name": "User", + "required": true + }, + "name": { + "type": "String", + "required": true + }, + "status": { + "type": "Entity", + "name": "Color", + "required": true + } + } + } + } + }, + "actions": { + "UpdateTask": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Task"] + } + }, + "CreateList": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Application"] + } + } + } + } +} diff --git a/CedarJava/src/test/resources/invalid_enum_entities.json b/CedarJava/src/test/resources/invalid_enum_entities.json new file mode 100644 index 0000000..add9da0 --- /dev/null +++ b/CedarJava/src/test/resources/invalid_enum_entities.json @@ -0,0 +1,23 @@ +[ + { + "uid": {"type": "User", "id": "alice"}, + "attrs": { + "name": "Alice" + }, + "parents": [] + }, + { + "uid": {"type": "Color", "id": "Purple"}, + "attrs": {}, + "parents": [] + }, + { + "uid": {"type": "Task", "id": "task1"}, + "attrs": { + "owner": {"type": "User", "id": "alice"}, + "name": "Complete project", + "status": {"type": "Color", "id": "Purple"} + }, + "parents": [] + } +]