From 73fcd7c77bf384a7ec4ca83dc513537d3c5128d2 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 12 Aug 2025 13:07:03 -0400 Subject: [PATCH 1/7] adds DateTime extension value Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/DateTime.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java new file mode 100644 index 00000000..0795d83d --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java @@ -0,0 +1,121 @@ +/* + * 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.value; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Represents a Cedar datetime extension value. DateTime values are encoded as strings in the + * following formats (Follows ISO 8601 standard): + * + * "YYYY-MM-DD" (date only) + * "YYYY-MM-DDThh:mm:ssZ" (UTC) + * "YYYY-MM-DDThh:mm:ss.SSSZ" (UTC with millisecond precision) + * "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (With timezone offset in hours and minutes) + * "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (With timezone offset in hours and minutes and millisecond precision) + * + */ +public class DateTime extends Value { + + private static class DateTimeValidator { + + private static final List FORMATTERS = + Arrays.asList(DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'").withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssXX").withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXX").withResolverStyle(ResolverStyle.STRICT)); + + /** + * Validates a datetime string against the supported formats. Automatically enforces range + * constraints: - Month: 01-12 - Day: 01-31 (considering month-specific limits) - Hour: + * 00-23 - Minute: 00-59 - Second: 00-59 + * + * @param dateTimeString the string to validate + * @return true if valid, false otherwise + */ + public static boolean isValid(String dateTimeString) { + if (dateTimeString == null || dateTimeString.trim().isEmpty()) { + return false; + } + + return FORMATTERS.stream() + .anyMatch(formatter -> canParse(formatter, dateTimeString)); + } + + private static boolean canParse(DateTimeFormatter formatter, String dateTimeString) { + try { + formatter.parse(dateTimeString); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + } + + /** Datetime as a string. */ + private final String dateTime; + + /** + * Construct DateTime. + * + * @param dateTime DateTime as a String. + */ + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + public DateTime(String dateTime) throws NullPointerException, IllegalArgumentException { + if (!DateTimeValidator.isValid(dateTime)) { + throw new IllegalArgumentException( + "Input string is not a valid DateTime format: " + dateTime); + } + this.dateTime = dateTime; + } + + /** Convert DateTime to Cedar expr that can be used in a Cedar policy. */ + @Override + public String toCedarExpr() { + return "datetime(\"" + dateTime + "\")"; + } + + /** Equals. */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DateTime dateTime1 = (DateTime) o; + return dateTime.equals(dateTime1.dateTime); + } + + /** Hash. */ + @Override + public int hashCode() { + return Objects.hash(dateTime); + } + + /** As a string. */ + @Override + public String toString() { + return dateTime; + } +} From 2fb452faf3e4c258663104a2479df7de5be692f2 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 12 Aug 2025 13:07:36 -0400 Subject: [PATCH 2/7] adds serializer/deserializer for DateTime Signed-off-by: Mudit Chaudhary --- .../com/cedarpolicy/serializer/ValueDeserializer.java | 6 ++++-- .../com/cedarpolicy/serializer/ValueSerializer.java | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java index f36c3fde..1172e48e 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueDeserializer.java @@ -20,6 +20,7 @@ import com.cedarpolicy.model.exception.InvalidValueDeserializationException; import com.cedarpolicy.value.CedarList; import com.cedarpolicy.value.CedarMap; +import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; import com.cedarpolicy.value.EntityIdentifier; import com.cedarpolicy.value.EntityTypeName; @@ -74,8 +75,7 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro } else if (node.isObject()) { Iterator> iter = node.fields(); // Do two passes, one to check if it is an escaped entity or extension and a second to - // write into a - // map + // write into a map EscapeType escapeType = EscapeType.UNRECOGNIZED; int count = 0; while (iter.hasNext()) { @@ -127,6 +127,8 @@ public Value deserialize(JsonParser parser, DeserializationContext context) thro return new Decimal(arg.textValue()); } else if (fn.textValue().equals("unknown")) { return new Unknown(arg.textValue()); + } else if (fn.textValue().equals("datetime")) { + return new DateTime(arg.textValue()); } else { throw new InvalidValueDeserializationException(parser, "Invalid function type: " + fn.toString(), node.asToken(), Map.class); diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java index 4bf45ded..301c5a03 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/ValueSerializer.java @@ -19,6 +19,7 @@ import com.cedarpolicy.model.exception.InvalidValueSerializationException; import com.cedarpolicy.value.CedarList; import com.cedarpolicy.value.CedarMap; +import com.cedarpolicy.value.DateTime; import com.cedarpolicy.value.Decimal; import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.IpAddress; @@ -102,6 +103,16 @@ public void serialize( jsonGenerator.writeString(value.toString()); jsonGenerator.writeEndObject(); jsonGenerator.writeEndObject(); + } else if (value instanceof DateTime) { + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName(EXTENSION_ESCAPE_SEQ); + jsonGenerator.writeStartObject(); + jsonGenerator.writeFieldName("fn"); + jsonGenerator.writeString("datetime"); + jsonGenerator.writeFieldName("arg"); + jsonGenerator.writeString(value.toString()); + jsonGenerator.writeEndObject(); + jsonGenerator.writeEndObject(); } else { // It is recommended that you extend the Value classes in // main.java.com.cedarpolicy.model.value or that you convert your class to a CedarMap From 7474f1a238f400169291a7d509164000d4f2181b Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 12 Aug 2025 13:07:51 -0400 Subject: [PATCH 3/7] adds DateTime tests Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/DateTimeTests.java | 335 ++++++++++++++++++ .../com/cedarpolicy/pbt/IntegrationTests.java | 101 ++++++ 2 files changed, 436 insertions(+) create mode 100644 CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java diff --git a/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java b/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java new file mode 100644 index 00000000..f90ecc80 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java @@ -0,0 +1,335 @@ +/* + * 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; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import com.cedarpolicy.value.DateTime; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; + +public class DateTimeTests { + + @Test + public void testValidDateOnlyFormat() { + String validDate = "2023-12-25"; + DateTime dateTime = new DateTime(validDate); + + assertEquals(validDate, dateTime.toString()); + } + + @Test + public void testValidDateTimeWithZFormat() { + String validDateTime = "2023-12-25T10:30:45Z"; + DateTime dateTime = new DateTime(validDateTime); + + assertEquals(validDateTime, dateTime.toString()); + } + + @Test + public void testValidDateTimeWithMillisecondsZFormat() { + String validDateTime = "2023-12-25T10:30:45.123Z"; + DateTime dateTime = new DateTime(validDateTime); + + assertEquals(validDateTime, dateTime.toString()); + } + + @Test + public void testValidDateTimeWithTimezoneOffsetFormat() { + String validDateTime = "2023-12-25T10:30:45+0500"; + DateTime dateTime = new DateTime(validDateTime); + + assertEquals(validDateTime, dateTime.toString()); + } + + @Test + public void testValidDateTimeWithMillisecondsAndTimezoneOffsetFormat() { + String validDateTime = "2023-12-25T10:30:45.123-0300"; + DateTime dateTime = new DateTime(validDateTime); + + assertEquals(validDateTime, dateTime.toString()); + } + + @Test + public void testValidDateTimeEdgeCases() { + // Test leap year + String leapYear = "2024-02-29"; + DateTime dateTime1 = new DateTime(leapYear); + assertEquals(leapYear, dateTime1.toString()); + + // Test end of year + String endOfYear = "2023-12-31T23:59:59Z"; + DateTime dateTime2 = new DateTime(endOfYear); + assertEquals(endOfYear, dateTime2.toString()); + + // Test beginning of year + String beginningOfYear = "2023-01-01T00:00:00Z"; + DateTime dateTime3 = new DateTime(beginningOfYear); + assertEquals(beginningOfYear, dateTime3.toString()); + } + + @Test + public void testInvalidDateTimeFormatsThrowException() { + // Test null input + assertThrows(IllegalArgumentException.class, () -> { + new DateTime(null); + }); + + // Test empty string + assertThrows(IllegalArgumentException.class, () -> { + new DateTime(""); + }); + + // Test whitespace-only string + assertThrows(IllegalArgumentException.class, () -> { + new DateTime(" "); + }); + + // Test invalid date format + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023/12/25"); + }); + + // Test invalid month + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-15-25"); + }); + + // Test invalid day + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-32"); + }); + + // Test invalid hour + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T25:30:45Z"); + }); + + // Test invalid minute + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:60:45Z"); + }); + + // Test invalid second + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:60Z"); + }); + + // Test invalid leap year date + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-02-29"); // 2023 is not a leap year + }); + + // Test invalid timezone format + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+ABC"); + }); + + // Test millisecond precision beyond the pattern (more than 3 digits) + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45.1234Z"); + }); + + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45.12345+0500"); + }); + + // Test half-formed timezone offset (missing minutes) + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+09"); + }); + + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45-05"); + }); + + // Test incorrect timezone offset format (with colon separator) + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+08:30"); + }); + + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45-09:45"); + }); + + // Test timezone offset with invalid hour values + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+2600"); + }); + + // Test timezone offset with invalid minute values + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45-0875"); + }); + + // Test timezone offset with only one digit + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+5"); + }); + + // Test timezone offset with too many digits + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:45+05000"); + }); + + // Test timezone offset without sign + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10:30:450800"); + }); + + // Test malformed string + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("not-a-date"); + }); + + // Test incomplete datetime + assertThrows(IllegalArgumentException.class, () -> { + new DateTime("2023-12-25T10"); + }); + } + + @Test + public void testEqualsAndHashCode() { + String dateTimeString = "2023-12-25T10:30:45Z"; + DateTime dateTime1 = new DateTime(dateTimeString); + DateTime dateTime2 = new DateTime(dateTimeString); + DateTime dateTime3 = new DateTime("2023-12-25T10:30:46Z"); // Different second + + // Test equals + assertEquals(dateTime1, dateTime2); + assertEquals(dateTime1, dateTime1); // Self equality + assertNotEquals(dateTime1, dateTime3); + assertNotEquals(dateTime1, null); + assertNotEquals(dateTime1, "not a DateTime"); + + // Test hashCode consistency + assertEquals(dateTime1.hashCode(), dateTime2.hashCode()); + } + + @Test + public void testToString() { + String dateTimeString = "2023-12-25T10:30:45.123Z"; + DateTime dateTime = new DateTime(dateTimeString); + + assertEquals(dateTimeString, dateTime.toString()); + } + + @Test + public void testToCedarExpr() { + String dateTimeString = "2023-12-25"; + DateTime dateTime = new DateTime(dateTimeString); + + assertEquals("datetime(\"2023-12-25\")", dateTime.toCedarExpr()); + } + + @Test + public void testToCedarExprWithComplexDateTime() { + String dateTimeString = "2023-12-25T10:30:45.123+0500"; + DateTime dateTime = new DateTime(dateTimeString); + + assertEquals("datetime(\"2023-12-25T10:30:45.123+0500\")", dateTime.toCedarExpr()); + } + + @Test + public void testDateTimeValidatorBoundaryConditions() { + // Test minimum CE valid value + DateTime minDate = new DateTime("0001-01-01"); + assertEquals("0001-01-01", minDate.toString()); + + // Test maximum month and day values + DateTime maxMonthDay = new DateTime("2023-12-31"); + assertEquals("2023-12-31", maxMonthDay.toString()); + + // Test maximum time values + DateTime maxTime = new DateTime("2023-12-25T23:59:59Z"); + assertEquals("2023-12-25T23:59:59Z", maxTime.toString()); + + // Test minimum time values + DateTime minTime = new DateTime("2023-12-25T00:00:00Z"); + assertEquals("2023-12-25T00:00:00Z", minTime.toString()); + + // Test maximum milliseconds + DateTime maxMillis = new DateTime("2023-12-25T10:30:45.999Z"); + assertEquals("2023-12-25T10:30:45.999Z", maxMillis.toString()); + } + + @Test + public void testEqualityWithDifferentFormats() { + DateTime date1 = new DateTime("2023-12-25"); + DateTime date2 = new DateTime("2023-12-25T00:00:00Z"); + + // Since we store the original string, these should not be equal + assertNotEquals(date1, date2); + assertNotEquals(date1.toString(), date2.toString()); + } + + @Test + public void testValidJsonSerialization() throws JsonProcessingException { + String dateTimeString = "2023-12-25T10:30:45Z"; + DateTime dateTime = new DateTime(dateTimeString); + + String json = CedarJson.objectWriter().writeValueAsString(dateTime); + String expectedJson = "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"2023-12-25T10:30:45Z\"}}"; + + assertEquals(expectedJson, json); + } + + @Test + public void testValidJsonDeserialization() throws IOException { + String json = "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"2023-12-25T10:30:45.123+0500\"}}"; + + DateTime dateTime = CedarJson.objectReader().readValue(json, DateTime.class); + + assertEquals("2023-12-25T10:30:45.123+0500", dateTime.toString()); + assertEquals("datetime(\"2023-12-25T10:30:45.123+0500\")", dateTime.toCedarExpr()); + } + + @Test + public void testInvalidJsonDeserialization() { + // Test that invalid JSON throws appropriate exceptions + String invalidJson = "{\"__extn\":{\"fn\":\"datetime\",\"arg\":\"invalid-date\"}}"; + + assertThrows(Exception.class, () -> { + CedarJson.objectReader().readValue(invalidJson, DateTime.class); + }); + } + + @Test + public void testJsonRoundTrip() throws IOException { + String[] testDates = {"2023-12-25", "2023-12-25T10:30:45Z", "2023-12-25T10:30:45.123Z", + "2023-12-25T10:30:45+0500", "2023-12-25T10:30:45.999-0800"}; + + for (String dateTimeString : testDates) { + DateTime original = new DateTime(dateTimeString); + + // Serialize to JSON + String json = CedarJson.objectWriter().writeValueAsString(original); + + // Deserialize back from JSON + DateTime deserialized = CedarJson.objectReader().readValue(json, DateTime.class); + + // Verify they are equal + assertEquals(original, deserialized); + assertEquals(original.toString(), deserialized.toString()); + assertEquals(original.toCedarExpr(), deserialized.toCedarExpr()); + } + } + +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java b/CedarJava/src/test/java/com/cedarpolicy/pbt/IntegrationTests.java index c728bc81..7cc34977 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.value.DateTime; import com.cedarpolicy.value.Decimal; import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.EntityTypeName; @@ -457,6 +458,106 @@ public void testIpAddressExtension() { assertAllowed(request, policySet, entities); } + /** Test DateTime extension. */ + @Test + public void testDateTimeExtension() { + Set entities = new HashSet<>(); + String principalId = "alice"; + Map principalAttributes = new HashMap<>(); + principalAttributes.put("DOB", new DateTime("2000-01-01")); + Set principalParents = new HashSet<>(); + Entity principal = new Entity(new EntityUID(principalType, principalId), principalAttributes, principalParents); + entities.add(principal); + + String actionId = "view"; + Map actionAttributes = new HashMap<>(); + Set actionParents = new HashSet<>(); + Entity action = new Entity(new EntityUID(actionType, actionId), actionAttributes, actionParents); + entities.add(action); + + String resourceId = "photo.jpg"; + Map resourceAttributes = new HashMap<>(); + Set resourceParents = new HashSet<>(); + var resource = new Entity(new EntityUID(resourceType, resourceId), resourceAttributes, resourceParents); + entities.add(resource); + + String p = + "permit(\n" + + "principal==" + + principal.getEUID().toString() + + ",\n" + + "action==" + + action.getEUID().toString() + + ",\n" + + "resource==" + + resource.getEUID().toString() + + "\n" + + ")\n" + + " when {\n" + + "principal.DOB > datetime(\"1999-01-01\")\n" + + "};"; + final String policyId = "ID0"; + Policy policy = new Policy(p, policyId); + Set policies = new HashSet<>(); + policies.add(policy); + PolicySet policySet = new PolicySet(policies); + Map currentContext = new HashMap<>(); + AuthorizationRequest request = + new AuthorizationRequest( + principal, action, resource, currentContext); + assertAllowed(request, policySet, entities); + } + + /** Test DateTime extension with different timezones. */ + @Test + public void testDateTimeExtensionWithDifferentTimezones() { + Set entities = new HashSet<>(); + String principalId = "alice"; + Map principalAttributes = new HashMap<>(); + principalAttributes.put("DOB", new DateTime("2023-12-25T10:30:45-0800")); + Set principalParents = new HashSet<>(); + Entity principal = new Entity(new EntityUID(principalType, principalId), principalAttributes, principalParents); + entities.add(principal); + + String actionId = "view"; + Map actionAttributes = new HashMap<>(); + Set actionParents = new HashSet<>(); + Entity action = new Entity(new EntityUID(actionType, actionId), actionAttributes, actionParents); + entities.add(action); + + String resourceId = "photo.jpg"; + Map resourceAttributes = new HashMap<>(); + Set resourceParents = new HashSet<>(); + var resource = new Entity(new EntityUID(resourceType, resourceId), resourceAttributes, resourceParents); + entities.add(resource); + + String p = + "permit(\n" + + "principal==" + + principal.getEUID().toString() + + ",\n" + + "action==" + + action.getEUID().toString() + + ",\n" + + "resource==" + + resource.getEUID().toString() + + "\n" + + ")\n" + + " when {\n" + + "principal.DOB > datetime(\"2023-12-25T14:30:20-0400\")\n" + + "};"; + final String policyId = "ID0"; + Policy policy = new Policy(p, policyId); + Set policies = new HashSet<>(); + policies.add(policy); + PolicySet policySet = new PolicySet(policies); + Map currentContext = new HashMap<>(); + AuthorizationRequest request = + new AuthorizationRequest( + principal, action, resource, currentContext); + assertAllowed(request, policySet, entities); + } + /** Test Decimal extension. */ @Test public void testDecimalExtension() { From 35eddf6148f6c8834d767447d487021ce9d81f8a Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 12 Aug 2025 13:14:17 -0400 Subject: [PATCH 4/7] fixes nits Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/DateTime.java | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java index 0795d83d..628cc2df 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java @@ -1,15 +1,17 @@ /* * 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 + * 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 + * 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. + * 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.value; @@ -26,23 +28,26 @@ * Represents a Cedar datetime extension value. DateTime values are encoded as strings in the * following formats (Follows ISO 8601 standard): * - * "YYYY-MM-DD" (date only) - * "YYYY-MM-DDThh:mm:ssZ" (UTC) - * "YYYY-MM-DDThh:mm:ss.SSSZ" (UTC with millisecond precision) - * "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (With timezone offset in hours and minutes) - * "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (With timezone offset in hours and minutes and millisecond precision) + * "YYYY-MM-DD" (date only) "YYYY-MM-DDThh:mm:ssZ" (UTC) "YYYY-MM-DDThh:mm:ss.SSSZ" (UTC with + * millisecond precision) "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (With timezone offset in hours and minutes) + * "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (With timezone offset in hours and minutes and millisecond + * precision) * */ public class DateTime extends Value { private static class DateTimeValidator { - private static final List FORMATTERS = - Arrays.asList(DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT), - DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'").withResolverStyle(ResolverStyle.STRICT), - DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withResolverStyle(ResolverStyle.STRICT), - DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssXX").withResolverStyle(ResolverStyle.STRICT), - DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXX").withResolverStyle(ResolverStyle.STRICT)); + private static final List FORMATTERS = Arrays.asList( + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss'Z'") + .withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'") + .withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssXX") + .withResolverStyle(ResolverStyle.STRICT), + DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXX") + .withResolverStyle(ResolverStyle.STRICT)); /** * Validates a datetime string against the supported formats. Automatically enforces range @@ -57,8 +62,7 @@ public static boolean isValid(String dateTimeString) { return false; } - return FORMATTERS.stream() - .anyMatch(formatter -> canParse(formatter, dateTimeString)); + return FORMATTERS.stream().anyMatch(formatter -> canParse(formatter, dateTimeString)); } private static boolean canParse(DateTimeFormatter formatter, String dateTimeString) { From 96489705bb1eb50ea4d51c3e1bd1371722cb8bd5 Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Tue, 12 Aug 2025 14:22:41 -0400 Subject: [PATCH 5/7] updates CHANGELOG Signed-off-by: Mudit Chaudhary --- CedarJava/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CedarJava/CHANGELOG.md b/CedarJava/CHANGELOG.md index dc1fe4b7..72fb7fdd 100644 --- a/CedarJava/CHANGELOG.md +++ b/CedarJava/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## Unreleased +* Added Schema conversion APIs [#325](https://github.com/cedar-policy/cedar-java/pull/325) +* Added Level Validation [#327](https://github.com/cedar-policy/cedar-java/pull/327) +* Added DateTime extension support [#328](https://github.com/cedar-policy/cedar-java/pull/328) + +## 4.3.1 ### Added * Added Zig version validation for publishing artifacts [#306](https://github.com/cedar-policy/cedar-java/pull/306) From 30d093b19967314c35c8b30417435f31c0e640ba Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Wed, 13 Aug 2025 15:46:51 -0400 Subject: [PATCH 6/7] updates DateTime to use semantic equality Signed-off-by: Mudit Chaudhary --- .../java/com/cedarpolicy/value/DateTime.java | 74 ++++++++++++++----- .../java/com/cedarpolicy/DateTimeTests.java | 54 +++++++++++++- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java index 628cc2df..e5348ae4 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java @@ -17,12 +17,17 @@ package com.cedarpolicy.value; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Represents a Cedar datetime extension value. DateTime values are encoded as strings in the @@ -50,27 +55,44 @@ private static class DateTimeValidator { .withResolverStyle(ResolverStyle.STRICT)); /** - * Validates a datetime string against the supported formats. Automatically enforces range - * constraints: - Month: 01-12 - Day: 01-31 (considering month-specific limits) - Hour: - * 00-23 - Minute: 00-59 - Second: 00-59 + * Parses a datetime string and returns the parsed Instant. Combines validation and parsing + * into a single operation to avoid redundancy. All datetime formats are normalized to + * Instant for consistent equality comparison. * - * @param dateTimeString the string to validate - * @return true if valid, false otherwise + * @param dateTimeString the string to parse + * @return Optional containing the parsed Instant, or empty if parsing fails */ - public static boolean isValid(String dateTimeString) { + private static Optional parseToInstant(String dateTimeString) { if (dateTimeString == null || dateTimeString.trim().isEmpty()) { - return false; + return java.util.Optional.empty(); } - return FORMATTERS.stream().anyMatch(formatter -> canParse(formatter, dateTimeString)); + return FORMATTERS.stream() + .flatMap(formatter -> tryParseWithFormatter(dateTimeString, formatter).stream()) + .findFirst(); } - private static boolean canParse(DateTimeFormatter formatter, String dateTimeString) { + /** + * Attempts to parse a datetime string with a specific formatter. + * + * @param dateTimeString the string to parse + * @param formatter the formatter to use + * @return Optional containing the parsed Instant, or empty if parsing fails + */ + private static Optional tryParseWithFormatter(String dateTimeString, + DateTimeFormatter formatter) { try { - formatter.parse(dateTimeString); - return true; + if (formatter == FORMATTERS.get(0)) { + // Date-only format - convert to start of day UTC + LocalDate date = LocalDate.parse(dateTimeString, formatter); + return Optional.of(date.atStartOfDay(ZoneOffset.UTC).toInstant()); + } else { + // DateTime format - parse and convert to Instant + OffsetDateTime dateTime = OffsetDateTime.parse(dateTimeString, formatter); + return Optional.of(dateTime.toInstant()); + } } catch (DateTimeParseException e) { - return false; + return Optional.empty(); } } } @@ -78,6 +100,9 @@ private static boolean canParse(DateTimeFormatter formatter, String dateTimeStri /** Datetime as a string. */ private final String dateTime; + /** Parsed datetime as Instant for semantic comparison. */ + private final Instant parsedInstant; + /** * Construct DateTime. * @@ -85,11 +110,14 @@ private static boolean canParse(DateTimeFormatter formatter, String dateTimeStri */ @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") public DateTime(String dateTime) throws NullPointerException, IllegalArgumentException { - if (!DateTimeValidator.isValid(dateTime)) { + Optional parsed = DateTimeValidator.parseToInstant(dateTime); + if (parsed.isEmpty()) { throw new IllegalArgumentException( - "Input string is not a valid DateTime format: " + dateTime); + "Input string is not a supported DateTime format: " + dateTime); + } else { + this.dateTime = dateTime; + this.parsedInstant = parsed.get(); } - this.dateTime = dateTime; } /** Convert DateTime to Cedar expr that can be used in a Cedar policy. */ @@ -98,7 +126,11 @@ public String toCedarExpr() { return "datetime(\"" + dateTime + "\")"; } - /** Equals. */ + /** + * Equals based on semantic comparison of the parsed datetime values. Two DateTime objects are + * equal if they represent the same instant in time, regardless of their string representation + * format. + */ @Override public boolean equals(Object o) { if (this == o) { @@ -107,14 +139,16 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { return false; } - DateTime dateTime1 = (DateTime) o; - return dateTime.equals(dateTime1.dateTime); + DateTime other = (DateTime) o; + return Objects.equals(this.parsedInstant, other.parsedInstant); } - /** Hash. */ + /** + * Hash based on the parsed datetime value for semantic equality. + */ @Override public int hashCode() { - return Objects.hash(dateTime); + return Objects.hash(parsedInstant); } /** As a string. */ diff --git a/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java b/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java index f90ecc80..ece6c8a5 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java @@ -271,15 +271,63 @@ public void testDateTimeValidatorBoundaryConditions() { } @Test - public void testEqualityWithDifferentFormats() { + public void testSemanticEqualityWithDifferentFormats() { DateTime date1 = new DateTime("2023-12-25"); DateTime date2 = new DateTime("2023-12-25T00:00:00Z"); - // Since we store the original string, these should not be equal - assertNotEquals(date1, date2); + // With semantic equality, these should be equal as they represent the same instant + assertEquals(date1, date2); + assertEquals(date1.hashCode(), date2.hashCode()); + + // But their string representations remain different assertNotEquals(date1.toString(), date2.toString()); } + @Test + public void testSemanticEqualityWithTimezones() { + // These all represent the same instant: noon UTC on Dec 25, 2023 + DateTime utc = new DateTime("2023-12-25T12:00:00Z"); + DateTime eastern = new DateTime("2023-12-25T07:00:00-0500"); + DateTime pacific = new DateTime("2023-12-25T04:00:00-0800"); + DateTime plus5 = new DateTime("2023-12-25T17:00:00+0500"); + + // All should be semantically equal + assertEquals(utc, eastern); + assertEquals(utc, pacific); + assertEquals(utc, plus5); + assertEquals(eastern, pacific); + assertEquals(eastern, plus5); + assertEquals(pacific, plus5); + + // Hash codes should match + assertEquals(utc.hashCode(), eastern.hashCode()); + assertEquals(utc.hashCode(), pacific.hashCode()); + assertEquals(utc.hashCode(), plus5.hashCode()); + + // But string representations should be different + assertNotEquals(utc.toString(), eastern.toString()); + assertNotEquals(utc.toString(), pacific.toString()); + assertNotEquals(utc.toString(), plus5.toString()); + } + + @Test + public void testSemanticEqualityWithMilliseconds() { + // Test that millisecond precision affects equality + DateTime withMillis = new DateTime("2023-12-25T12:00:12.000Z"); + DateTime withoutMillis = new DateTime("2023-12-25T12:00:00Z"); + DateTime differentMillis = new DateTime("2023-12-25T12:00:00.456Z"); + + // These should not be equal due to different milliseconds + assertNotEquals(withMillis, withoutMillis); + assertNotEquals(withMillis, differentMillis); + assertNotEquals(withoutMillis, differentMillis); + + // Same milliseconds should be equal + DateTime sameMillis = new DateTime("2023-12-25T12:00:12Z"); + assertEquals(withMillis, sameMillis); + assertEquals(withMillis.hashCode(), sameMillis.hashCode()); + } + @Test public void testValidJsonSerialization() throws JsonProcessingException { String dateTimeString = "2023-12-25T10:30:45Z"; From 072fbf484a58fa0f31467d086e03e659c8f284bf Mon Sep 17 00:00:00 2001 From: Mudit Chaudhary Date: Wed, 13 Aug 2025 16:02:01 -0400 Subject: [PATCH 7/7] fixes javadoc nits Signed-off-by: Mudit Chaudhary --- .../src/main/java/com/cedarpolicy/value/DateTime.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java index e5348ae4..1e8b4cc1 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java +++ b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java @@ -33,10 +33,11 @@ * Represents a Cedar datetime extension value. DateTime values are encoded as strings in the * following formats (Follows ISO 8601 standard): * - * "YYYY-MM-DD" (date only) "YYYY-MM-DDThh:mm:ssZ" (UTC) "YYYY-MM-DDThh:mm:ss.SSSZ" (UTC with - * millisecond precision) "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (With timezone offset in hours and minutes) - * "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (With timezone offset in hours and minutes and millisecond - * precision) + * "YYYY-MM-DD" (date only) + * "YYYY-MM-DDThh:mm:ssZ" (UTC) + * "YYYY-MM-DDThh:mm:ss.SSSZ" (UTC with millisecond precision) + * "YYYY-MM-DDThh:mm:ss(+/-)hhmm" (With timezone offset in hours and minutes) + * "YYYY-MM-DDThh:mm:ss.SSS(+/-)hhmm" (With timezone offset in hours and minutes and millisecond precision) * */ public class DateTime extends Value {