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/model/AuthorizationRequest.java b/CedarJava/src/main/java/com/cedarpolicy/model/AuthorizationRequest.java index 8ab9021a..6176a9d7 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/model/AuthorizationRequest.java +++ b/CedarJava/src/main/java/com/cedarpolicy/model/AuthorizationRequest.java @@ -95,6 +95,34 @@ public AuthorizationRequest( this.enableRequestValidation = enableRequestValidation; } + /** + * Create an authorization request from the EUIDs and Context. + * Constructor overloading to support Context object while preserving backward compatability. + * + * @param principalEUID Principal's EUID. + * @param actionEUID Action's EUID. + * @param resourceEUID Resource's EUID. + * @param context Context object. + * @param schema Schema (optional). + * @param enableRequestValidation Whether to use the schema for just + * schema-based parsing of `context` (false) or also for request validation + * (true). No effect if `schema` is not provided. + */ + public AuthorizationRequest( + EntityUID principalEUID, + EntityUID actionEUID, + EntityUID resourceEUID, + Context context, + Optional schema, + boolean enableRequestValidation) { + this.principalEUID = principalEUID; + this.actionEUID = actionEUID; + this.resourceEUID = resourceEUID; + this.context = Optional.of(context.getContext()); + this.schema = schema; + this.enableRequestValidation = enableRequestValidation; +} + /** * Create a request without a schema. * @@ -113,6 +141,25 @@ public AuthorizationRequest(EntityUID principalEUID, EntityUID actionEUID, Entit false); } + /** + * Create a request without a schema. + * Constructor overloading to support Context object while preserving backward compatability. + * + * @param principalEUID Principal's EUID. + * @param actionEUID Action's EUID. + * @param resourceEUID Resource's EUID. + * @param context Key/Value context. + */ + public AuthorizationRequest(EntityUID principalEUID, EntityUID actionEUID, EntityUID resourceEUID, Context context) { + this( + principalEUID, + actionEUID, + resourceEUID, + context, + Optional.empty(), + false); + } + /** * Create a request without a schema, using Entity objects for principal/action/resource. * @@ -129,6 +176,24 @@ public AuthorizationRequest(Entity principalEUID, Entity actionEUID, Entity reso context); } + /** + * Create a request without a schema, using Entity objects for principal/action/resource. + * Constructor overloading to support Context object while preserving backward compatability. + * + * @param principalEUID Principal's EUID. + * @param actionEUID Action's EUID. + * @param resourceEUID Resource's EUID. + * @param context Key/Value context. + */ + public AuthorizationRequest(Entity principalEUID, Entity actionEUID, Entity resourceEUID, Context context) { + this( + principalEUID.getEUID(), + actionEUID.getEUID(), + resourceEUID.getEUID(), + context); + } + + /** * Create a request from Entity objects and Context. * @@ -154,6 +219,31 @@ public AuthorizationRequest(Entity principal, Entity action, Entity resource, ); } + /** + * Create a request from Entity objects and Context. + * Constructor overloading to support Context object while preserving backward compatability. + * + * @param principal + * @param action + * @param resource + * @param context + * @param schema + * @param enableRequestValidation Whether to use the schema for just + * schema-based parsing of `context` (false) or also for request validation + * (true). No effect if `schema` is not provided. + */ + + public AuthorizationRequest(Entity principal, Entity action, Entity resource, + Context context, Optional schema, boolean enableRequestValidation) { + this( + principal.getEUID(), + action.getEUID(), + resource.getEUID(), + context, + schema, + enableRequestValidation + ); + } /** Readable string representation. */ @Override diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/Context.java b/CedarJava/src/main/java/com/cedarpolicy/model/Context.java new file mode 100644 index 00000000..3250a64b --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/Context.java @@ -0,0 +1,145 @@ +/* +* 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.model; + +import java.util.HashMap; +import java.util.Collections; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import java.util.Map; +import com.cedarpolicy.value.Value; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; + +public class Context { + + private Map context; + + /** + * Constructs a new empty Context with no key-value pairs. Initializes the internal context map as an empty + * immutable map. + */ + public Context() { + context = Collections.emptyMap(); + } + + public boolean isEmpty() { + return context.isEmpty(); + } + + /** + * Constructs a new Context from an Iterable of key-value pairs. Creates a new HashMap and populates it with the + * provided entries. Equivalent to from_pairs in Cedar Rust. + * + * @param contextList An Iterable containing key-value pairs to initialize this context with + * @throws IllegalStateException if a duplicate key is found within the iterable + * @throws IllegalArgumentException if the contextList parameter is null + */ + @SuppressFBWarnings("CT_CONSTRUCTOR_THROW") + public Context(Iterable> contextList) { + context = new HashMap<>(); + mergeContextFromIterable(contextList); + } + + /** + * Constructs a new Context with the provided map of key-value pairs. Creates a defensive copy of the input map to + * maintain immutability. + * + * @param contextMap The map of key-value pairs to initialize this context with + * @throws IllegalArgumentException if the contextMap parameter is null + */ + public Context(Map contextMap) { + context = new HashMap<>(); + context.putAll(contextMap); + } + + /** + * Returns a defensive copy of the internal context map. + * + * @return A new HashMap containing all key-value pairs from the internal context + */ + public Map getContext() { + return new HashMap<>(context); + } + + /** + * Merges another Context object into the current context. + * + * @param contextToMerge The Context object to merge into this context + * @throws IllegalStateException if a duplicate key is found while merging the context + * @throws IllegalArgumentException if the contextToMerge parameter is null + */ + public void merge(Context contextToMerge) throws IllegalStateException, IllegalArgumentException { + mergeContextFromIterable(contextToMerge.getContext().entrySet()); + } + + /** + * Merges the provided key-value pairs into the current context. + * + * @param contextMaps An Iterable containing key-value pairs to merge into this context + * @throws IllegalStateException if a duplicate key is found in the existing context or duplicate key found + * within the iterable + * @throws IllegalArgumentException if the contextMaps parameter is null + */ + public void merge(Iterable> contextMaps) + throws IllegalStateException, IllegalArgumentException { + mergeContextFromIterable(contextMaps); + } + + /** + * Retrieves the Value associated with the specified key from the context. + * + * @param key The key whose associated Value is to be returned + * @return The Value associated with the specified key, or null if the key is not found replicating Cedar Rust + * behavior + * @throws IllegalArgumentException if the key parameter is null + */ + public Value get(String key) { + if (key == null) { + throw new IllegalArgumentException("Key cannot be null"); + } + return context.getOrDefault(key, null); + } + + /** + * Processes an Iterable of Map entries and adds them to the context. + * + * @param contextIterator The Iterable containing key-value pairs to add to the context + * @throws IllegalStateException if a duplicate key is found in the existing context or duplicate key found + * within the iterable + * @throws IllegalArgumentException if the contextIterator is null + */ + private void mergeContextFromIterable(Iterable> contextIterator) + throws IllegalStateException, IllegalArgumentException { + if (contextIterator == null) { + throw new IllegalArgumentException("Context iterator cannot be null"); + } + + Map newEntries = StreamSupport.stream(contextIterator.spliterator(), false).peek(entry -> { + if (context.containsKey(entry.getKey())) { + throw new IllegalStateException( + String.format("Duplicate key '%s' in existing context", entry.getKey())); + } + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + context.putAll(newEntries); + } + + /** Readable string representation. */ + @Override + public String toString() { + return context.toString(); + } +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/PartialAuthorizationRequest.java b/CedarJava/src/main/java/com/cedarpolicy/model/PartialAuthorizationRequest.java index c235daad..9520592d 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/model/PartialAuthorizationRequest.java +++ b/CedarJava/src/main/java/com/cedarpolicy/model/PartialAuthorizationRequest.java @@ -160,6 +160,11 @@ public Builder context(Map context) { return this; } + public Builder context(Context context) { + this.context = Optional.of(ImmutableMap.copyOf(context.getContext())); + return this; + } + /** * Set the context to be empty, not unknown * @return The builder. diff --git a/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java b/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java index 703f6baf..7b288b7c 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java +++ b/CedarJava/src/test/java/com/cedarpolicy/AuthTests.java @@ -27,11 +27,13 @@ import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; import com.cedarpolicy.model.entity.Entity; import com.cedarpolicy.model.policy.Policy; +import com.cedarpolicy.model.Context; import com.cedarpolicy.model.policy.PolicySet; import com.cedarpolicy.value.EntityTypeName; import com.cedarpolicy.value.EntityUID; import com.cedarpolicy.value.Unknown; import com.cedarpolicy.value.Value; +import com.cedarpolicy.value.PrimBool; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; @@ -63,6 +65,66 @@ public void simple() { assertAllowed(q, policySet, new HashSet<>()); } + private Set buildEntitiesForContextTests() { + EntityTypeName principalType = EntityTypeName.parse("User").get(); + EntityTypeName actionType = EntityTypeName.parse("Action").get(); + EntityTypeName albumResourceType = EntityTypeName.parse("Album").get(); + EntityTypeName photoResourceType = EntityTypeName.parse("Photo").get(); + + Set parents = new HashSet<>(); + Entity album = new Entity(albumResourceType.of("Vacation"), new HashMap<>(), new HashSet<>()); + parents.add(album.getEUID()); + Entity photo = new Entity(photoResourceType.of("pic01"), new HashMap<>(), parents); + + Set entities = new HashSet<>(); + entities.add(photo); + entities.add(album); + entities.add(new Entity(principalType.of("Alice"), new HashMap<>(), new HashSet<>())); + entities.add(new Entity(actionType.of("View_Photo"), new HashMap<>(), new HashSet<>())); + + return entities; + } + + private PolicySet buildPolicySetForContextTests() { + Set ps = new HashSet<>(); + String fullPolicy = + "permit(principal == User::\"Alice\", action == Action::\"View_Photo\", resource in Album::\"Vacation\")" + + "when {context.authenticated == true};"; + + Policy newPolicy = new Policy(fullPolicy, "p1"); + ps.add(newPolicy); + return new PolicySet(ps); + } + + @Test + public void authWithBackwardCompatibleContext() { + EntityUID principal = new EntityUID(EntityTypeName.parse("User").get(), "Alice"); + EntityUID action = new EntityUID(EntityTypeName.parse("Action").get(), "View_Photo"); + EntityUID resource = new EntityUID(EntityTypeName.parse("Photo").get(), "pic01"); + Set entities = buildEntitiesForContextTests(); + PolicySet policySet = buildPolicySetForContextTests(); + Map context = new HashMap<>(); + context.put("authenticated", new PrimBool(true)); + AuthorizationRequest r = new AuthorizationRequest(principal, action, resource, context); + + assertAllowed(r, policySet, entities); + } + + @Test + public void authWithContextObject() { + EntityUID principal = new EntityUID(EntityTypeName.parse("User").get(), "Alice"); + EntityUID action = new EntityUID(EntityTypeName.parse("Action").get(), "View_Photo"); + EntityUID resource = new EntityUID(EntityTypeName.parse("Photo").get(), "pic01"); + Set entities = buildEntitiesForContextTests(); + PolicySet policySet = buildPolicySetForContextTests(); + Map contextMap = new HashMap<>(); + contextMap.put("authenticated", new PrimBool(true)); + Context context = new Context(contextMap); + AuthorizationRequest r = new AuthorizationRequest(principal, action, resource, context); + + assertAllowed(r, policySet, entities); + } + @Test public void concrete() { var auth = new BasicAuthorizationEngine(); @@ -84,6 +146,57 @@ public void concrete() { }); } + @Test + public void partialAuthConcreteWithBackwardCompatibleContext() { + var auth = new BasicAuthorizationEngine(); + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + + Map context = new HashMap<>(); + context.put("authenticated", new PrimBool(true)); + + var q = PartialAuthorizationRequest.builder().principal(alice).action(view).resource(alice).context(context).build(); + + var policies = new HashSet(); + policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when {context.authenticated == true};", "p0")); + var policySet = new PolicySet(policies); + + assumePartialEvaluation(() -> { + try { + final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>()); + assertEquals(Decision.Allow, response.success.orElseThrow().getDecision()); + assertEquals(response.success.orElseThrow().getMustBeDetermining().iterator().next(), "p0"); + assertTrue(response.success.orElseThrow().getNontrivialResiduals().isEmpty()); + } catch (Exception e) { + fail("error: " + e.toString()); + } + }); + } + + @Test + public void partialAuthConcreteWithContextObject() { + var auth = new BasicAuthorizationEngine(); + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var view = new EntityUID(EntityTypeName.parse("Action").get(), "view"); + Map contextMap = new HashMap<>(); + contextMap.put("authenticated", new PrimBool(true)); + Context context = new Context(contextMap); + var q = PartialAuthorizationRequest.builder().principal(alice).action(view).resource(alice).context(context).build(); + var policies = new HashSet(); + policies.add(new Policy("permit(principal == User::\"alice\",action,resource) when {context.authenticated == true};", "p0")); + var policySet = new PolicySet(policies); + assumePartialEvaluation(() -> { + try { + final PartialAuthorizationResponse response = auth.isAuthorizedPartial(q, policySet, new HashSet<>()); + assertEquals(Decision.Allow, response.success.orElseThrow().getDecision()); + assertEquals(response.success.orElseThrow().getMustBeDetermining().iterator().next(), "p0"); + assertTrue(response.success.orElseThrow().getNontrivialResiduals().isEmpty()); + } catch (Exception e) { + fail("error: " + e.toString()); + } + }); + } + @Test public void residual() { var auth = new BasicAuthorizationEngine(); diff --git a/CedarJava/src/test/java/com/cedarpolicy/ContextTests.java b/CedarJava/src/test/java/com/cedarpolicy/ContextTests.java new file mode 100644 index 00000000..0251994d --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/ContextTests.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; + +import com.cedarpolicy.value.PrimBool; +import com.cedarpolicy.value.PrimLong; +import com.cedarpolicy.value.PrimString; +import com.cedarpolicy.value.Value; +import com.cedarpolicy.model.Context; + +import org.junit.jupiter.api.Test; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ContextTests { + + private Map getValidMap() { + Map expectedContextMap = new HashMap<>(); + expectedContextMap.put("key1", new PrimString("value1")); + expectedContextMap.put("key2", new PrimLong(999)); + expectedContextMap.put("key3", new PrimBool(true)); + + return expectedContextMap; + } + + private Map getValidMergedMap() { + Map expectedContextMap = new HashMap<>(); + expectedContextMap.put("key1", new PrimString("value1")); + expectedContextMap.put("key2", new PrimLong(999)); + expectedContextMap.put("key3", new PrimBool(true)); + expectedContextMap.put("key4", new PrimString("value1")); + expectedContextMap.put("key5", new PrimLong(999)); + + return expectedContextMap; + } + + private Iterable> getValidIterable() { + Set> iterableSet = new HashSet<>(); + iterableSet.add(new AbstractMap.SimpleEntry<>("key1", new PrimString("value1"))); + iterableSet.add(new AbstractMap.SimpleEntry<>("key2", new PrimLong(999))); + iterableSet.add(new AbstractMap.SimpleEntry<>("key3", new PrimBool(true))); + + return iterableSet; + } + + private Iterable> getInvalidIterable() { + Set> iterableSet = new HashSet<>(); + iterableSet.add(new AbstractMap.SimpleEntry<>("key1", new PrimString("value1"))); + iterableSet.add(new AbstractMap.SimpleEntry<>("key1", new PrimLong(999))); + iterableSet.add(new AbstractMap.SimpleEntry<>("key3", new PrimBool(true))); + + return iterableSet; + } + + + @Test + public void givenValidIterableConstructorConstructs() throws IllegalStateException { + Context validContext = new Context(getValidIterable()); + assertEquals(getValidMap(), validContext.getContext()); + } + + @Test + public void givenDuplicateKeyIterableConstructorThrows() throws IllegalStateException { + assertThrows(IllegalStateException.class, () -> { + Context validContext = new Context(getInvalidIterable()); + }); + } + + @Test + public void givenValidMapConstructorConstructs() throws IllegalStateException { + Context validContext = new Context(getValidMap()); + + assertEquals(getValidMap(), validContext.getContext()); + } + + @Test + public void givenValidIterableMergeMerges() throws IllegalStateException { + Context validContext = new Context(getValidMap()); + Map contextToMergeMap = new HashMap<>(); + contextToMergeMap.put("key4", new PrimString("value1")); + contextToMergeMap.put("key5", new PrimLong(999)); + validContext.merge(contextToMergeMap.entrySet()); + + assertEquals(getValidMergedMap(), validContext.getContext()); + } + + @Test + public void givenExistingKeyIterableMergeThrows() throws IllegalStateException { + Context context = new Context(getValidMap()); + Map contextToMergeMap = new HashMap<>(); + contextToMergeMap.put("key3", new PrimString("value1")); + contextToMergeMap.put("key5", new PrimLong(999)); + + assertThrows(IllegalStateException.class, () -> { + context.merge(contextToMergeMap.entrySet()); + }); + } + + @Test + public void givenValidContextMergeMerges() throws IllegalStateException { + Context validContext = new Context(getValidMap()); + Map contextToMergeMap = new HashMap<>(); + contextToMergeMap.put("key4", new PrimString("value1")); + contextToMergeMap.put("key5", new PrimLong(999)); + Context contextToMerge = new Context(contextToMergeMap); + validContext.merge(contextToMerge); + + assertEquals(getValidMergedMap(), validContext.getContext()); + } + + @Test + public void givenExistingKeyContextMergeThrows() throws IllegalStateException { + Context validContext = new Context(getValidMap()); + Map contextToMergeMap = new HashMap<>(); + contextToMergeMap.put("key3", new PrimString("value1")); + contextToMergeMap.put("key5", new PrimLong(999)); + Context contextToMerge = new Context(contextToMergeMap); + + assertThrows(IllegalStateException.class, () -> { + validContext.merge(contextToMerge); + }); + } + + @Test + public void givenValidKeyGetReturnsValue() { + Context validContext = new Context(getValidMap()); + + assertEquals(getValidMap().get("key1"), validContext.get("key1")); + } + + @Test + public void givenInvalidKeyGetReturnsNull() { + Context validContext = new Context(getValidMap()); + + assertEquals(null, validContext.get("invalidKey")); + } +}