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) 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 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..1e8b4cc1 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/DateTime.java @@ -0,0 +1,160 @@ +/* + * 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.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 + * 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)); + + /** + * 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 parse + * @return Optional containing the parsed Instant, or empty if parsing fails + */ + private static Optional parseToInstant(String dateTimeString) { + if (dateTimeString == null || dateTimeString.trim().isEmpty()) { + return java.util.Optional.empty(); + } + + return FORMATTERS.stream() + .flatMap(formatter -> tryParseWithFormatter(dateTimeString, formatter).stream()) + .findFirst(); + } + + /** + * 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 { + 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 Optional.empty(); + } + } + } + + /** Datetime as a string. */ + private final String dateTime; + + /** Parsed datetime as Instant for semantic comparison. */ + private final Instant parsedInstant; + + /** + * Construct DateTime. + * + * @param dateTime DateTime as a String. + */ + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + public DateTime(String dateTime) throws NullPointerException, IllegalArgumentException { + Optional parsed = DateTimeValidator.parseToInstant(dateTime); + if (parsed.isEmpty()) { + throw new IllegalArgumentException( + "Input string is not a supported DateTime format: " + dateTime); + } else { + this.dateTime = dateTime; + this.parsedInstant = parsed.get(); + } + } + + /** Convert DateTime to Cedar expr that can be used in a Cedar policy. */ + @Override + public String toCedarExpr() { + return "datetime(\"" + dateTime + "\")"; + } + + /** + * 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) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DateTime other = (DateTime) o; + return Objects.equals(this.parsedInstant, other.parsedInstant); + } + + /** + * Hash based on the parsed datetime value for semantic equality. + */ + @Override + public int hashCode() { + return Objects.hash(parsedInstant); + } + + /** As a string. */ + @Override + public String toString() { + return dateTime; + } +} 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..ece6c8a5 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/DateTimeTests.java @@ -0,0 +1,383 @@ +/* + * 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 testSemanticEqualityWithDifferentFormats() { + DateTime date1 = new DateTime("2023-12-25"); + DateTime date2 = new DateTime("2023-12-25T00:00:00Z"); + + // 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"; + 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() {