diff --git a/CedarJava/build.gradle b/CedarJava/build.gradle index ef800df1..45f0b000 100644 --- a/CedarJava/build.gradle +++ b/CedarJava/build.gradle @@ -184,7 +184,7 @@ tasks.register('compileFFI') { } exec { workingDir = ffiDir - commandLine 'cargo', '+' + RustVersion, 'zigbuild', '--features', 'partial-eval', '--release', '--target', rustTarget + commandLine 'cargo', '+' + RustVersion, 'zigbuild', '--features', 'partial-eval,tpe', '--release', '--target', rustTarget } def sourcePath = "${ffiDir}/target/${rustTarget}/release/${libraryFile}" @@ -206,7 +206,7 @@ tasks.register('testFFI') { doLast { exec { workingDir = ffiDir - commandLine 'cargo', 'test' + commandLine 'cargo', 'test', '--all-features' } } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java index a3b82e04..ff8d512e 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java +++ b/CedarJava/src/main/java/com/cedarpolicy/BasicAuthorizationEngine.java @@ -37,7 +37,6 @@ import com.cedarpolicy.model.exception.AuthException; import com.cedarpolicy.model.exception.BadRequestException; import com.cedarpolicy.model.exception.InternalException; -import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; import com.cedarpolicy.model.policy.PolicySet; import com.cedarpolicy.value.Value; import com.fasterxml.jackson.annotation.JsonCreator; @@ -110,11 +109,7 @@ public PartialAuthorizationResponse isAuthorizedPartial(com.cedarpolicy.model.Pa final PartialAuthorizationRequest request = new PartialAuthorizationRequest(q, policySet, entities); return call("AuthorizationPartialOperation", PartialAuthorizationResponse.class, request); } catch (InternalException e) { - if (e.getMessage().contains("AuthorizationPartialOperation")) { - throw new MissingExperimentalFeatureException(ExperimentalFeature.PARTIAL_EVALUATION); - } else { - throw e; - } + throw ExperimentalFeature.PARTIAL_EVALUATION.translateIfDisabled(e); } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java b/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java index 6900fc21..56c93c81 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java +++ b/CedarJava/src/main/java/com/cedarpolicy/CedarJson.java @@ -17,10 +17,12 @@ package com.cedarpolicy; import com.cedarpolicy.model.entity.Entity; +import com.cedarpolicy.model.entity.PartialEntity; import com.cedarpolicy.model.policy.PolicySet; import com.cedarpolicy.model.policy.TemplateLink; import com.cedarpolicy.model.schema.Schema; import com.cedarpolicy.serializer.EntitySerializer; +import com.cedarpolicy.serializer.PartialEntitySerializer; import com.cedarpolicy.serializer.PolicySetSerializer; import com.cedarpolicy.serializer.TemplateLinkSerializer; import com.cedarpolicy.serializer.SchemaSerializer; @@ -58,6 +60,7 @@ private static ObjectMapper createObjectMapper() { final SimpleModule module = new SimpleModule(); module.addSerializer(Entity.class, new EntitySerializer()); + module.addSerializer(PartialEntity.class, new PartialEntitySerializer()); module.addSerializer(Schema.class, new SchemaSerializer()); module.addSerializer(TemplateLink.class, new TemplateLinkSerializer()); module.addSerializer(PolicySet.class, new PolicySetSerializer()); diff --git a/CedarJava/src/main/java/com/cedarpolicy/ExperimentalFeature.java b/CedarJava/src/main/java/com/cedarpolicy/ExperimentalFeature.java index 9c76ca34..6c363e91 100644 --- a/CedarJava/src/main/java/com/cedarpolicy/ExperimentalFeature.java +++ b/CedarJava/src/main/java/com/cedarpolicy/ExperimentalFeature.java @@ -16,16 +16,42 @@ package com.cedarpolicy; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; + public enum ExperimentalFeature { /** Partial evaluation feature */ - PARTIAL_EVALUATION("partial-eval"); + PARTIAL_EVALUATION("partial-eval", "AuthorizationPartialOperation"), + /** Type-aware partial evaluation feature */ + TYPE_AWARE_PARTIAL_EVALUATION("tpe", "TypeAwarePartialEvaluationNotEnabled"); private String compileFlag; - ExperimentalFeature(String compileFlag) { + private String disabledToken; + + ExperimentalFeature(String compileFlag, String disabledToken) { this.compileFlag = compileFlag; + this.disabledToken = disabledToken; } public String getCompileFlag() { return this.compileFlag; } + + /** + * Translate the error a native call raises when the library was built without this feature into the exception the + * rest of the library uses to report a missing experimental feature. Every feature is detected by matching a token + * in the native error message, but the two features supply that token differently: a partial evaluation operation is + * compiled out of the native dispatch table, so the native library echoes the operation name back in its + * unsupported-operation error, whereas the type-aware partial evaluation entry points are compiled in either way and + * their disabled bodies return a token chosen for this purpose. + * + * @param e The exception the native call raised. + * @return A {@link MissingExperimentalFeatureException} if this feature is off, otherwise {@code e} unchanged. + */ + public InternalException translateIfDisabled(InternalException e) { + if (e.getMessage() != null && e.getMessage().contains(this.disabledToken)) { + return new MissingExperimentalFeatureException(this); + } + return e; + } } diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntities.java b/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntities.java new file mode 100644 index 00000000..8db86017 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntities.java @@ -0,0 +1,178 @@ +/* + * 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.entity; + +import com.cedarpolicy.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.cedarpolicy.loader.LibraryLoader; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; +import com.cedarpolicy.model.schema.Schema; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import static com.cedarpolicy.CedarJson.objectMapper; +import static com.cedarpolicy.CedarJson.objectWriter; + +/** + * A collection of partially known Cedar entities. Entities left out of the collection are entities whose existence, as + * well as all of whose data, is unknown. + * + *

Two of Cedar's checks span the whole collection and so can only run here: no two entities may share a UID, and an + * entity that supplies its parents may not name a parent that is present in this collection with its own parents + * omitted. Both are hard errors; see {@link PartialEntity} for why the second one is not a partial unknown. + * + *

An instance is immutable, as are the entities it holds. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +public final class PartialEntities { + static { + LibraryLoader.loadLibrary(); + } + + private final Set entities; + + private PartialEntities(Set entities) { + this.entities = Set.copyOf(entities); + } + + /** + * Returns the entities in this collection. + * + * @return An unmodifiable set of the PartialEntity objects in this collection + */ + public Set getEntities() { + return entities; + } + + /** + * Constructs a collection from a given Set of PartialEntity objects, checking the collection as a whole against the + * schema. + * + * @param entities The partially known entities. + * @param schema The schema to check the entities against. + * @throws InternalException If the entities do not check out against the schema, or if they cannot + * be serialized. + * @throws MissingExperimentalFeatureException If the native library was built without the {@code tpe} feature. + * @throws NullPointerException If the schema is null. + */ + public PartialEntities(Set entities, Schema schema) throws InternalException { + final String entitiesJson; + try { + entitiesJson = objectWriter().writeValueAsString(entities); + } catch (JsonProcessingException e) { + throw new InternalException("Failed to serialize the partial entities: " + e.getMessage()); + } + validate(entitiesJson, schema); + this.entities = Set.copyOf(entities); + } + + /** + * Constructs a collection from concrete entities. Each one is fully known, so its attributes, parents, and tags all + * come across as present rather than unknown. + * + * @param entities The concrete entities. + * @param schema The schema to check the entities against. + * @throws InternalException If the entities do not check out against the schema, or if they cannot + * be serialized. + * @throws MissingExperimentalFeatureException If the native library was built without the {@code tpe} feature. + * @throws NullPointerException If the schema is null. + */ + public PartialEntities(Entities entities, Schema schema) throws InternalException { + this(lift(entities), schema); + } + + /** + * Constructs a collection from Cedar's JSON encoding of partially known entities, which is an array of objects, each + * with a {@code uid} and with {@code attrs}, {@code parents}, and {@code tags} present only when they are known. + * + *

Attribute and tag values are read with the same plumbing as concrete entities, so an entity reference in a value + * must use the {@code __entity} escape rather than the bare {@code {"type", "id"}} form that Cedar also accepts when + * it parses against a schema. + * + * @param json The array of encoded entities. + * @param schema The schema to check the entities against. + * @return The collection. + * @throws InternalException If the entities do not check out against the schema, or if the encoding + * cannot be read. + * @throws MissingExperimentalFeatureException If the native library was built without the {@code tpe} feature. + * @throws NullPointerException If the JSON or the schema is null. + */ + public static PartialEntities fromJson(JsonNode json, Schema schema) throws InternalException { + if (!json.isArray()) { + throw new InternalException("Partially known entities must be encoded as a JSON array."); + } + validate(json.toString(), schema); + final ObjectMapper mapper = objectMapper(); + final Set entities = new HashSet<>(); + for (JsonNode entityJson : json) { + entities.add(PartialEntity.fromJson(entityJson, mapper)); + } + return new PartialEntities(entities); + } + + /** + * Constructs a collection in which nothing at all is known. + * + * @return The collection. + */ + public static PartialEntities empty() { + return new PartialEntities(new HashSet<>()); + } + + @Override + public String toString() { + return String.join("\n", this.entities.stream().map(PartialEntity::toString).toList()); + } + + private static void validate(String entitiesJson, Schema schema) throws InternalException { + Objects.requireNonNull(schema, "A schema is required because Cedar checks the supplied fields against it."); + final String schemaJson; + try { + schemaJson = objectWriter().writeValueAsString(schema); + } catch (JsonProcessingException e) { + throw new InternalException("Failed to serialize the schema: " + e.getMessage()); + } + try { + validatePartialEntitiesJni(entitiesJson, schemaJson); + } catch (InternalException e) { + throw ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION.translateIfDisabled(e); + } + } + + /** + * Lift concrete entities without checking each one, because the caller goes on to check the whole collection in a + * single native call, which subsumes the per-entity checks. + */ + private static Set lift(Entities entities) { + final Set lifted = new HashSet<>(); + for (Entity entity : entities.getEntities()) { + lifted.add(new PartialEntity(entity.getEUID(), Optional.of(entity.attrs), + Optional.of(entity.getParents()), Optional.of(entity.tags))); + } + return lifted; + } + + private static native String validatePartialEntitiesJni(String entitiesJson, String schemaJson) + throws InternalException; +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntity.java b/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntity.java new file mode 100644 index 00000000..e9d0bec5 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/model/entity/PartialEntity.java @@ -0,0 +1,262 @@ +/* + * 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.entity; + +import com.cedarpolicy.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.cedarpolicy.loader.LibraryLoader; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.exception.MissingExperimentalFeatureException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.serializer.JsonEUID; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.Value; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.cedarpolicy.CedarJson.objectWriter; + +/** + * An entity whose attributes, parents, and tags may be unknown. The EUID is always known; each of the three other + * fields is either absent, meaning unknown, or present, in which case it must be complete. Cedar checks any + * field that is supplied against the schema in full, and derives the transitive ancestor closure from the parents that + * are supplied, so an incomplete map or parent set is an error rather than a partial unknown. Unknown-ness is therefore + * per field, never per attribute or per tag. + * + *

Absent is not the same as empty: {@code Optional.empty()} leaves the attributes unknown, whereas an empty map + * states that the entity is known to have no attributes. Parents and tags behave the same way. + * + *

The two levels of unknown-ness are distinct. Omitting an entity from a {@link PartialEntities} altogether leaves + * both its existence and all of its data unknown, while including it here with a field omitted asserts that the entity + * does exist and leaves only that one field unknown. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +public final class PartialEntity { + private static final TypeReference> VALUE_MAP = new TypeReference<>() { + }; + + static { + LibraryLoader.loadLibrary(); + } + + private final EntityUID euid; + private final Optional> attrs; + private final Optional> parents; + private final Optional> tags; + + /** + * Construct a partial entity, checking every field that was supplied against the schema. Mirrors + * {@code PartialEntity::new}. Each of the three optional fields is either absent, meaning unknown, or present and + * complete: Cedar cannot tell whether a supplied map or parent set is complete without the schema, so the schema is + * required. + * + * @param euid EUID of the entity. + * @param attrs The attributes, absent if unknown, empty if the entity is known to have none. + * @param parents The direct parents, absent if unknown, empty if the entity is known to have none. + * @param tags The tags, absent if unknown, empty if the entity is known to have none. + * @param schema The schema to check the entity against. + * @throws InternalException If the entity does not check out against the schema, or if it cannot + * be serialized. + * @throws MissingExperimentalFeatureException If the native library was built without the {@code tpe} feature. + * @throws NullPointerException If the schema is null. + */ + public PartialEntity(EntityUID euid, Optional> attrs, Optional> parents, + Optional> tags, Schema schema) throws InternalException { + this(euid, attrs, parents, tags); + validate(schema); + } + + /** + * Lift a fully known entity: its attributes, parents, and tags are all present. + * + * @param entity The concrete entity. + * @param schema The schema to check the entity against. + * @throws InternalException If the entity does not check out against the schema, or if it cannot + * be serialized. + * @throws MissingExperimentalFeatureException If the native library was built without the {@code tpe} feature. + * @throws NullPointerException If the schema is null. + */ + public PartialEntity(Entity entity, Schema schema) throws InternalException { + this(entity.getEUID(), Optional.of(entity.attrs), Optional.of(entity.getParents()), Optional.of(entity.tags), + schema); + } + + /** + * Construct a partial entity without checking it against a schema. Only for callers that go on to have Cedar check + * the whole collection in a single native call, which subsumes the per-entity checks and additionally covers the + * rules that span the collection. In practice that means {@link PartialEntities}, on both the JSON and the concrete + * path. + */ + PartialEntity(EntityUID euid, Optional> attrs, Optional> parents, + Optional> tags) { + this.euid = euid; + this.attrs = attrs.map(Map::copyOf); + this.parents = parents.map(Set::copyOf); + this.tags = tags.map(Map::copyOf); + } + + /** + * Read a partial entity from Cedar's JSON encoding, without checking it against a schema. Only for callers that go + * on to have Cedar check the whole collection, as described on the constructor this delegates to. The encoding is an + * object with a {@code uid} and with {@code attrs}, {@code parents}, and {@code tags} present only when they are + * known. + * + * @param json The encoded entity. + * @param mapper The mapper to read attribute and tag values with. + * @return The partial entity. + * @throws InternalException If the encoding cannot be read. + */ + static PartialEntity fromJson(JsonNode json, ObjectMapper mapper) throws InternalException { + if (!json.has("uid")) { + throw new InternalException("A partially known entity must have a \"uid\" field: " + json); + } + Optional> attrs = Optional.empty(); + if (json.hasNonNull("attrs")) { + attrs = Optional.of(parseValueMap(mapper, json.get("attrs"), "attrs")); + } + Optional> parents = Optional.empty(); + if (json.hasNonNull("parents")) { + final Set directParents = new HashSet<>(); + for (JsonNode parent : json.get("parents")) { + directParents.add(parseEntityUID(parent)); + } + parents = Optional.of(directParents); + } + Optional> tags = Optional.empty(); + if (json.hasNonNull("tags")) { + tags = Optional.of(parseValueMap(mapper, json.get("tags"), "tags")); + } + return new PartialEntity(parseEntityUID(json.get("uid")), attrs, parents, tags); + } + + private static EntityUID parseEntityUID(JsonNode json) throws InternalException { + final JsonNode euid = json.has("__entity") ? json.get("__entity") : json; + if (!euid.has("type") || !euid.has("id")) { + throw new InternalException("An entity UID must have \"type\" and \"id\" fields: " + json); + } + final String type = euid.get("type").asText(); + return EntityUID.parseFromJson(new JsonEUID(type, euid.get("id").asText())) + .orElseThrow(() -> new InternalException("Invalid entity type name: " + type)); + } + + private static Map parseValueMap(ObjectMapper mapper, JsonNode json, String field) + throws InternalException { + try { + return mapper.convertValue(json, VALUE_MAP); + } catch (IllegalArgumentException e) { + throw new InternalException("Failed to read \"" + field + "\": " + e.getMessage()); + } + } + + private void validate(Schema schema) throws InternalException { + Objects.requireNonNull(schema, "A schema is required because Cedar checks the supplied fields against it."); + final String entityJson; + try { + entityJson = objectWriter().writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new InternalException("Failed to serialize the partial entity: " + e.getMessage()); + } + final String schemaJson; + try { + schemaJson = objectWriter().writeValueAsString(schema); + } catch (JsonProcessingException e) { + throw new InternalException("Failed to serialize the schema: " + e.getMessage()); + } + try { + validatePartialEntityJni(entityJson, schemaJson); + } catch (InternalException e) { + throw ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION.translateIfDisabled(e); + } + } + + /** + * Get the EUID of this entity, which is always known. + * + * @return The EUID. + */ + public EntityUID getEUID() { + return euid; + } + + /** + * Get the attributes of this entity. + * + * @return An unmodifiable map of the attributes, or absent if they are unknown. + */ + public Optional> getAttrs() { + return attrs; + } + + /** + * Get the direct parents of this entity, from which Cedar derives the transitive closure. + * + * @return An unmodifiable set of the direct parents, or absent if they are unknown. + */ + public Optional> getParents() { + return parents; + } + + /** + * Get the tags of this entity. + * + * @return An unmodifiable map of the tags, or absent if they are unknown. + */ + public Optional> getTags() { + return tags; + } + + /** + * A debug rendering, laid out like {@link Entity#toString()}. Nothing in the translation layer reads it: the wire + * form is produced by {@link com.cedarpolicy.serializer.PartialEntitySerializer} from the getters. A field that is + * unknown is labelled as such, while a field that is known to be empty is omitted, the same as on a concrete + * entity. + * + * @return a debug rendering of this partial entity + */ + @Override + public String toString() { + return euid.toString() + + section("parents", parents.map(ps -> ps.stream().map(EntityUID::toString))) + + section("attrs", attrs.map(PartialEntity::renderEntries)) + + section("tags", tags.map(PartialEntity::renderEntries)); + } + + private static Stream renderEntries(Map values) { + return values.entrySet().stream().map(e -> e.getKey() + ": " + e.getValue()); + } + + private static String section(String label, Optional> entries) { + if (entries.isEmpty()) { + return "\n\t" + label + ": unknown"; + } + final String body = entries.get().collect(Collectors.joining("\n\t\t")); + return body.isEmpty() ? "" : "\n\t" + label + ":\n\t\t" + body; + } + + private static native String validatePartialEntityJni(String entityJson, String schemaJson) + throws InternalException; +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/serializer/PartialEntitySerializer.java b/CedarJava/src/main/java/com/cedarpolicy/serializer/PartialEntitySerializer.java new file mode 100644 index 00000000..c54ee12f --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/serializer/PartialEntitySerializer.java @@ -0,0 +1,52 @@ +/* + * Copyright Cedar Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.cedarpolicy.serializer; + +import com.cedarpolicy.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.cedarpolicy.model.entity.PartialEntity; +import com.cedarpolicy.value.EntityUID; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.util.stream.Collectors; + +/** Serialize a partial entity. An omitted field is the wire encoding of unknown. */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +public class PartialEntitySerializer extends JsonSerializer { + + /** Serialize a partial entity. */ + @Override + public void serialize( + PartialEntity entity, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + jsonGenerator.writeStartObject(); + jsonGenerator.writeObjectField("uid", entity.getEUID().asJson()); + if (entity.getAttrs().isPresent()) { + jsonGenerator.writeObjectField("attrs", entity.getAttrs().get()); + } + if (entity.getParents().isPresent()) { + jsonGenerator.writeObjectField("parents", + entity.getParents().get().stream().map(EntityUID::asJson).collect(Collectors.toSet())); + } + if (entity.getTags().isPresent()) { + jsonGenerator.writeObjectField("tags", entity.getTags().get()); + } + jsonGenerator.writeEndObject(); + } +} diff --git a/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java b/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java new file mode 100644 index 00000000..87af6d60 --- /dev/null +++ b/CedarJava/src/main/java/com/cedarpolicy/value/PartialEntityUID.java @@ -0,0 +1,124 @@ +/* + * 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 com.cedarpolicy.Experimental; +import com.cedarpolicy.ExperimentalFeature; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import java.util.Objects; +import java.util.Optional; + +/** + * An entity UID whose id may be unknown. The entity type is always known: type-aware partial evaluation requires the + * type of an unknown principal or resource in order to type check the request. + * + *

This is the partial counterpart to {@link EntityUID} and holds the same component types, {@link EntityTypeName} + * and {@link EntityIdentifier}, so that an id that is present has already been through the same validation as a + * concrete one. + */ +@Experimental(ExperimentalFeature.TYPE_AWARE_PARTIAL_EVALUATION) +@JsonInclude(JsonInclude.Include.NON_ABSENT) +public final class PartialEntityUID { + private final EntityTypeName type; + private final Optional id; + + /** + * Construct a partial EUID from a type name and an optional id. Mirrors {@code PartialEntityUid::new}. + * + * @param type the Entity Type of this EUID + * @param id the id portion of the EUID, absent if unknown + */ + public PartialEntityUID(EntityTypeName type, Optional id) { + this.type = type; + this.id = id; + } + + /** + * Construct a partial EUID whose id is known. + * + * @param type the Entity Type of this EUID + * @param id the id portion of the EUID + */ + public PartialEntityUID(EntityTypeName type, EntityIdentifier id) { + this(type, Optional.of(id)); + } + + /** + * Construct a partial EUID of the given type whose id is unknown. + * + * @param type the Entity Type of this EUID + */ + public PartialEntityUID(EntityTypeName type) { + this(type, Optional.empty()); + } + + /** + * Construct a fully known partial EUID from a concrete EUID. + * + * @param euid the concrete EUID + */ + public PartialEntityUID(EntityUID euid) { + this(euid.getType(), euid.getId()); + } + + /** + * Get the Type of this EUID. + * + * @return The EntityTypeName portion of this EUID. + */ + @JsonProperty("type") + @JsonSerialize(using = ToStringSerializer.class) + public EntityTypeName getType() { + return type; + } + + /** + * Get the ID of this EUID, absent if unknown. + * + * @return The EntityIdentifier portion of this EUID, absent if unknown. + */ + @JsonProperty("id") + @JsonSerialize(contentUsing = ToStringSerializer.class) + public Optional getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (o == null) { + return false; + } else if (o == this) { + return true; + } else { + try { + PartialEntityUID rhs = (PartialEntityUID) o; + return this.type.equals(rhs.type) && this.id.equals(rhs.id); + } catch (ClassCastException e) { + return false; + } + } + } + + @Override + public int hashCode() { + return Objects.hash(type, id); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/PartialEntitiesTests.java b/CedarJava/src/test/java/com/cedarpolicy/PartialEntitiesTests.java new file mode 100644 index 00000000..10f1fff2 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/PartialEntitiesTests.java @@ -0,0 +1,269 @@ +/* + * 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.model.entity.Entities; +import com.cedarpolicy.model.entity.Entity; +import com.cedarpolicy.model.entity.PartialEntities; +import com.cedarpolicy.model.entity.PartialEntity; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.PrimLong; +import com.cedarpolicy.value.PrimString; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import static com.cedarpolicy.TestUtil.assertMessageContains; +import static com.cedarpolicy.TestUtil.buildEuidObject; +import static com.cedarpolicy.TestUtil.buildUidObject; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests for {@link PartialEntities}, a collection of partially known entities. */ +public class PartialEntitiesTests { + /** See {@link PartialEntityTests} for what this schema is shaped to catch. */ + private static final Schema TPE_SCHEMA = TestUtil.loadSchemaResource("/tpe_schema.json"); + + /** The same schema in Cedar format, which reaches the other branch of the FFI's schema parsing. */ + private static final Schema TPE_SCHEMA_CEDAR = TestUtil.loadCedarSchemaResource("/tpe_schema.cedarschema"); + + @Test + public void testChecksAgainstCedarFormatSchema() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var entities = Set.of( + new PartialEntity(alice, Optional.empty(), Optional.empty(), Optional.empty(), TPE_SCHEMA_CEDAR)); + assertEquals(1, new PartialEntities(entities, TPE_SCHEMA_CEDAR).getEntities().size()); + + // A collection-level rule, which only the collection validator can catch. + var duplicated = Set.of( + new PartialEntity(alice, Optional.empty(), Optional.empty(), Optional.empty(), TPE_SCHEMA_CEDAR), + new PartialEntity(alice, Optional.of(Map.of()), Optional.empty(), Optional.empty(), TPE_SCHEMA_CEDAR)); + InternalException e = + assertThrows(InternalException.class, () -> new PartialEntities(duplicated, TPE_SCHEMA_CEDAR)); + assertMessageContains(e, "duplicate entity entry", "User::\"alice\""); + } + + @Test + public void testRejectsParentWithUnknownParents() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + // `admins` is in the collection with its own parents unknown, so Cedar cannot close the hierarchy over it. + var entities = Set.of( + new PartialEntity(alice, Optional.empty(), Optional.of(Set.of(admins)), Optional.empty(), TPE_SCHEMA), + new PartialEntity(admins, Optional.empty(), Optional.empty(), Optional.empty(), TPE_SCHEMA)); + InternalException e = + assertThrows(InternalException.class, () -> new PartialEntities(entities, TPE_SCHEMA)); + assertMessageContains(e, "ancestor `Group::\"admins\"`", "of `User::\"alice\"`", "has unknown ancestors"); + } + + @Test + public void testAllowsParentAbsentFromTheCollection() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + var child = + new PartialEntity(alice, Optional.empty(), Optional.of(Set.of(admins)), Optional.empty(), TPE_SCHEMA); + + // Nothing is claimed about `admins`, so naming it as a parent is fine. + assertEquals(1, new PartialEntities(Set.of(child), TPE_SCHEMA).getEntities().size()); + + // Stating that `admins` has no parents is also fine: the hierarchy can be closed. + var noParents = + new PartialEntity(admins, Optional.empty(), Optional.of(Set.of()), Optional.empty(), TPE_SCHEMA); + var withParent = new PartialEntities(Set.of(child, noParents), TPE_SCHEMA); + assertEquals(2, withParent.getEntities().size()); + } + + @Test + public void testRejectsDuplicateUid() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var entities = Set.of( + new PartialEntity(alice, Optional.empty(), Optional.empty(), Optional.empty(), TPE_SCHEMA), + new PartialEntity(alice, Optional.of(Map.of()), Optional.empty(), Optional.empty(), TPE_SCHEMA)); + InternalException e = + assertThrows(InternalException.class, () -> new PartialEntities(entities, TPE_SCHEMA)); + assertMessageContains(e, "duplicate entity entry", "User::\"alice\""); + } + + @Test + public void testOfConcreteEntitiesLiftsEveryField() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + var entities = new Entities(Set.of( + new Entity(alice, Map.of("department", new PrimString("eng")), Set.of(admins), + Map.of("stage", new PrimString("beta"))), + new Entity(admins, Map.of(), Set.of(), Map.of()))); + + var lifted = new PartialEntities(entities, TPE_SCHEMA); + assertEquals(2, lifted.getEntities().size()); + + // A concrete entity is fully known, so every field must come across as present rather than unknown. + PartialEntity liftedAlice = lifted.getEntities().stream() + .filter(e -> e.getEUID().equals(alice)) + .findFirst() + .orElseThrow(); + assertEquals(Map.of("department", new PrimString("eng")), liftedAlice.getAttrs().orElseThrow()); + assertEquals(Set.of(admins), liftedAlice.getParents().orElseThrow()); + assertEquals(Map.of("stage", new PrimString("beta")), liftedAlice.getTags().orElseThrow()); + + // `admins` is present with no parents, which is what lets Cedar close the hierarchy above. + PartialEntity liftedAdmins = lifted.getEntities().stream() + .filter(e -> e.getEUID().equals(admins)) + .findFirst() + .orElseThrow(); + assertEquals(Set.of(), liftedAdmins.getParents().orElseThrow()); + assertEquals(Map.of(), liftedAdmins.getAttrs().orElseThrow()); + } + + @Test + public void testOfConcreteEntitiesChecksAgainstSchema() { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var entities = new Entities(Set.of( + new Entity(alice, Map.of("department", new PrimLong(3L)), Set.of(), Map.of()))); + InternalException e = + assertThrows(InternalException.class, () -> new PartialEntities(entities, TPE_SCHEMA)); + assertMessageContains(e, "attribute `department`", "User::\"alice\"", "type mismatch", + "expected to have type string", "actually has type long"); + } + + @Test + public void testFromJsonRoundTrip() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + + ObjectNode attrs = JsonNodeFactory.instance.objectNode(); + attrs.put("department", "eng"); + ObjectNode tags = JsonNodeFactory.instance.objectNode(); + tags.put("stage", "beta"); + ArrayNode parents = JsonNodeFactory.instance.arrayNode(); + // Cedar accepts either uid encoding, so exercise the escaped one here and the flat one for the uid itself. + parents.add(buildEuidObject("Group", "admins")); + ObjectNode aliceJson = JsonNodeFactory.instance.objectNode(); + aliceJson.set("uid", buildUidObject("User", "alice")); + aliceJson.set("attrs", attrs); + aliceJson.set("parents", parents); + aliceJson.set("tags", tags); + ArrayNode json = JsonNodeFactory.instance.arrayNode(); + json.add(aliceJson); + + var entities = PartialEntities.fromJson(json, TPE_SCHEMA).getEntities(); + assertEquals(1, entities.size()); + PartialEntity parsed = entities.iterator().next(); + assertEquals(alice, parsed.getEUID()); + assertEquals(Map.of("department", new PrimString("eng")), parsed.getAttrs().orElseThrow()); + assertEquals(Set.of(admins), parsed.getParents().orElseThrow()); + assertEquals(Map.of("stage", new PrimString("beta")), parsed.getTags().orElseThrow()); + + // A field left out of the encoding stays unknown rather than becoming empty. + ObjectNode existsOnlyJson = JsonNodeFactory.instance.objectNode(); + existsOnlyJson.set("uid", buildUidObject("User", "alice")); + var existsOnly = PartialEntities + .fromJson(JsonNodeFactory.instance.arrayNode().add(existsOnlyJson), TPE_SCHEMA) + .getEntities() + .iterator() + .next(); + assertTrue(existsOnly.getAttrs().isEmpty()); + assertTrue(existsOnly.getParents().isEmpty()); + assertTrue(existsOnly.getTags().isEmpty()); + + // An explicit null is what serde maps to None, so it must also stay unknown rather than becoming empty. + ObjectNode nullFieldsJson = JsonNodeFactory.instance.objectNode(); + nullFieldsJson.set("uid", buildUidObject("User", "alice")); + nullFieldsJson.putNull("attrs"); + nullFieldsJson.putNull("parents"); + nullFieldsJson.putNull("tags"); + var nullFields = PartialEntities + .fromJson(JsonNodeFactory.instance.arrayNode().add(nullFieldsJson), TPE_SCHEMA) + .getEntities() + .iterator() + .next(); + assertTrue(nullFields.getAttrs().isEmpty()); + assertTrue(nullFields.getParents().isEmpty()); + assertTrue(nullFields.getTags().isEmpty()); + + // A field encoded as present but empty is the opposite claim: the entity is known to have none of them, so it + // must come back present-and-empty rather than unknown. + ObjectNode emptyFieldsJson = JsonNodeFactory.instance.objectNode(); + emptyFieldsJson.set("uid", buildUidObject("User", "alice")); + emptyFieldsJson.set("attrs", JsonNodeFactory.instance.objectNode()); + emptyFieldsJson.set("parents", JsonNodeFactory.instance.arrayNode()); + emptyFieldsJson.set("tags", JsonNodeFactory.instance.objectNode()); + var emptyFields = PartialEntities + .fromJson(JsonNodeFactory.instance.arrayNode().add(emptyFieldsJson), TPE_SCHEMA) + .getEntities() + .iterator() + .next(); + assertEquals(Map.of(), emptyFields.getAttrs().orElseThrow()); + assertEquals(Set.of(), emptyFields.getParents().orElseThrow()); + assertEquals(Map.of(), emptyFields.getTags().orElseThrow()); + } + + @Test + public void testFromJsonRejectsDuplicateUid() { + // The same rule as `testRejectsDuplicateUid`, reached through the JSON encoding. Worth covering separately + // because a JSON array can carry two entries with the same uid, whereas a `Set` relies on the elements + // differing to keep both. + ObjectNode first = JsonNodeFactory.instance.objectNode(); + first.set("uid", buildUidObject("User", "alice")); + ObjectNode second = JsonNodeFactory.instance.objectNode(); + second.set("uid", buildUidObject("User", "alice")); + second.set("attrs", JsonNodeFactory.instance.objectNode()); + ArrayNode json = JsonNodeFactory.instance.arrayNode().add(first).add(second); + + InternalException e = + assertThrows(InternalException.class, () -> PartialEntities.fromJson(json, TPE_SCHEMA)); + assertMessageContains(e, "duplicate entity entry", "User::\"alice\""); + } + + @Test + public void testFromJsonRejectsEntityThatDoesNotConformToSchema() { + // An attribute of the wrong type. Unlike a non-array payload, this is only detectable by Cedar, so it exercises + // the native validation that `fromJson` runs before it parses anything. + ObjectNode attrs = JsonNodeFactory.instance.objectNode(); + attrs.put("department", 3); + ObjectNode wrongType = JsonNodeFactory.instance.objectNode(); + wrongType.set("uid", buildUidObject("User", "alice")); + wrongType.set("attrs", attrs); + InternalException e = assertThrows(InternalException.class, () -> PartialEntities + .fromJson(JsonNodeFactory.instance.arrayNode().add(wrongType), TPE_SCHEMA)); + assertMessageContains(e, "attribute `department`", "User::\"alice\"", "type mismatch", + "expected to have type string", "actually has type long"); + + // An entity type the schema does not declare. + ObjectNode undeclaredType = JsonNodeFactory.instance.objectNode(); + undeclaredType.set("uid", buildUidObject("Album", "trip")); + InternalException undeclared = assertThrows(InternalException.class, () -> PartialEntities + .fromJson(JsonNodeFactory.instance.arrayNode().add(undeclaredType), TPE_SCHEMA)); + assertMessageContains(undeclared, "entity `Album::\"trip\"`", "type `Album`", + "not declared in the schema"); + } + + @Test + public void testFromJsonRejectsNonArrayJson() { + assertThrows(InternalException.class, + () -> PartialEntities.fromJson(JsonNodeFactory.instance.objectNode(), TPE_SCHEMA)); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/PartialEntityTests.java b/CedarJava/src/test/java/com/cedarpolicy/PartialEntityTests.java new file mode 100644 index 00000000..3de726e1 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/PartialEntityTests.java @@ -0,0 +1,177 @@ +/* + * 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.model.entity.Entity; +import com.cedarpolicy.model.entity.PartialEntity; +import com.cedarpolicy.model.exception.InternalException; +import com.cedarpolicy.model.schema.Schema; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.PrimLong; +import com.cedarpolicy.value.PrimString; +import com.cedarpolicy.value.Value; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import static com.cedarpolicy.TestUtil.assertJSONEqual; +import static com.cedarpolicy.TestUtil.assertMessageContains; +import static com.cedarpolicy.TestUtil.buildUidObject; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Tests for {@link PartialEntity}, an entity whose attributes, parents, and tags may be unknown. */ +public class PartialEntityTests { + /** + * Schema for the type-aware partial evaluation tests. {@code User} has a typed attribute, a declared tag type, and a + * declared parent type, so an entity can be made to fail against it for one specific reason at a time. Both the + * attribute and the context attribute are optional so that a field stated to be empty still checks out. + */ + private static final Schema TPE_SCHEMA = TestUtil.loadSchemaResource("/tpe_schema.json"); + + /** The same schema in Cedar format, which reaches the other branch of the FFI's schema parsing. */ + private static final Schema TPE_SCHEMA_CEDAR = TestUtil.loadCedarSchemaResource("/tpe_schema.cedarschema"); + + @Test + public void testSerialization() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + + ObjectNode attrs = JsonNodeFactory.instance.objectNode(); + attrs.put("department", "eng"); + ObjectNode n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + n.set("attrs", attrs); + assertJSONEqual(n, new PartialEntity(alice, Optional.of(Map.of("department", new PrimString("eng"))), + Optional.empty(), Optional.empty(), TPE_SCHEMA)); + + ArrayNode parents = JsonNodeFactory.instance.arrayNode(); + parents.add(buildUidObject("Group", "admins")); + n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + n.set("parents", parents); + assertJSONEqual(n, new PartialEntity(alice, Optional.empty(), Optional.of(Set.of(admins)), + Optional.empty(), TPE_SCHEMA)); + + ObjectNode tags = JsonNodeFactory.instance.objectNode(); + tags.put("stage", "beta"); + n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + n.set("tags", tags); + assertJSONEqual(n, new PartialEntity(alice, Optional.empty(), Optional.empty(), + Optional.of(Map.of("stage", new PrimString("beta"))), TPE_SCHEMA)); + + n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + assertJSONEqual(n, new PartialEntity(alice, Optional.empty(), Optional.empty(), Optional.empty(), + TPE_SCHEMA)); + + n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + n.set("attrs", JsonNodeFactory.instance.objectNode()); + n.set("parents", JsonNodeFactory.instance.arrayNode()); + n.set("tags", JsonNodeFactory.instance.objectNode()); + assertJSONEqual(n, new PartialEntity(alice, Optional.of(Map.of()), Optional.of(Set.of()), + Optional.of(Map.of()), TPE_SCHEMA)); + } + + @Test + public void testOfEntityMatchesEntitySerializer() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + var entity = new Entity(alice, Map.of("department", new PrimString("eng")), Set.of(admins), + Map.of("stage", new PrimString("beta"))); + + ObjectNode attrs = JsonNodeFactory.instance.objectNode(); + attrs.put("department", "eng"); + ArrayNode parents = JsonNodeFactory.instance.arrayNode(); + parents.add(buildUidObject("Group", "admins")); + ObjectNode tags = JsonNodeFactory.instance.objectNode(); + tags.put("stage", "beta"); + ObjectNode n = JsonNodeFactory.instance.objectNode(); + n.set("uid", buildUidObject("User", "alice")); + n.set("attrs", attrs); + n.set("parents", parents); + n.set("tags", tags); + + assertJSONEqual(n, entity); + assertJSONEqual(n, new PartialEntity(entity, TPE_SCHEMA)); + } + + @Test + public void testToStringLabelsUnknownFieldsAndOmitsEmptyOnes() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + + // A fully known entity renders like its concrete counterpart, so compare against one. + var known = new PartialEntity(alice, Optional.of(Map.of("department", new PrimString("eng"))), + Optional.of(Set.of(admins)), Optional.of(Map.of("stage", new PrimString("beta"))), TPE_SCHEMA); + assertEquals(new Entity(alice, Map.of("department", new PrimString("eng")), Set.of(admins), + Map.of("stage", new PrimString("beta"))).toString(), known.toString()); + assertEquals("User::\"alice\"" + + "\n\tparents:\n\t\tGroup::\"admins\"" + + "\n\tattrs:\n\t\tdepartment: eng" + + "\n\ttags:\n\t\tstage: beta", known.toString()); + + // An unknown field is labelled, which is the one thing a concrete entity cannot express. + var unknown = new PartialEntity(alice, Optional.empty(), Optional.empty(), Optional.empty(), TPE_SCHEMA); + assertEquals("User::\"alice\"" + + "\n\tparents: unknown" + + "\n\tattrs: unknown" + + "\n\ttags: unknown", unknown.toString()); + + // A field known to be empty is omitted rather than labelled, matching a concrete entity with nothing set. + var empty = new PartialEntity(alice, Optional.of(Map.of()), Optional.of(Set.of()), Optional.of(Map.of()), + TPE_SCHEMA); + assertEquals("User::\"alice\"", empty.toString()); + assertEquals(new Entity(alice).toString(), empty.toString()); + } + + @Test + public void testRejectsAttributeOfTheWrongType() { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + Map attrs = Map.of("department", new PrimLong(3L)); + InternalException e = assertThrows(InternalException.class, + () -> new PartialEntity(alice, Optional.of(attrs), Optional.empty(), Optional.empty(), TPE_SCHEMA)); + assertMessageContains(e, "attribute `department`", "User::\"alice\"", "type mismatch", + "expected to have type string", "actually has type long"); + } + + @Test + public void testChecksAgainstCedarFormatSchema() throws InternalException { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + var admins = new EntityUID(EntityTypeName.parse("Group").get(), "admins"); + + new PartialEntity(alice, Optional.of(Map.of("department", new PrimString("eng"))), + Optional.of(Set.of(admins)), Optional.of(Map.of("stage", new PrimString("beta"))), TPE_SCHEMA_CEDAR); + + // The same violation the JSON-format schema catches, to show the Cedar-format schema is applied and not merely + // parsed into something permissive. + Map wrongType = Map.of("department", new PrimLong(3L)); + InternalException e = assertThrows(InternalException.class, () -> new PartialEntity(alice, + Optional.of(wrongType), Optional.empty(), Optional.empty(), TPE_SCHEMA_CEDAR)); + assertMessageContains(e, "attribute `department`", "User::\"alice\"", "type mismatch", + "expected to have type string", "actually has type long"); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/PartialEntityUIDTests.java b/CedarJava/src/test/java/com/cedarpolicy/PartialEntityUIDTests.java new file mode 100644 index 00000000..6809ca65 --- /dev/null +++ b/CedarJava/src/test/java/com/cedarpolicy/PartialEntityUIDTests.java @@ -0,0 +1,55 @@ +/* + * 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.EntityIdentifier; +import com.cedarpolicy.value.EntityTypeName; +import com.cedarpolicy.value.EntityUID; +import com.cedarpolicy.value.PartialEntityUID; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import static com.cedarpolicy.TestUtil.assertJSONEqual; +import static com.cedarpolicy.TestUtil.buildUidObject; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Tests for {@link PartialEntityUID}, an entity UID whose id may be unknown. */ +public class PartialEntityUIDTests { + + @Test + public void testSerialization() { + var unknownUser = new PartialEntityUID(EntityTypeName.parse("User").get()); + assertJSONEqual(buildUidObject("User"), unknownUser); + + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + assertJSONEqual(buildUidObject("User", "alice"), new PartialEntityUID(alice)); + + var quoted = EntityUID.parse("User::\"ali\\\"ce\"").get(); + assertJSONEqual(buildUidObject("User", "ali\"ce"), new PartialEntityUID(quoted)); + } + + @Test + public void testConstructorsAreInterchangeable() { + var alice = new EntityUID(EntityTypeName.parse("User").get(), "alice"); + assertEquals(new PartialEntityUID(alice), + new PartialEntityUID(EntityTypeName.parse("User").get(), new EntityIdentifier("alice"))); + assertEquals(new PartialEntityUID(EntityTypeName.parse("User").get()), + new PartialEntityUID(EntityTypeName.parse("User").get(), Optional.empty())); + } +} diff --git a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java index 3e99ae78..e5d646e8 100644 --- a/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java +++ b/CedarJava/src/test/java/com/cedarpolicy/TestUtil.java @@ -24,6 +24,12 @@ import com.cedarpolicy.model.policy.Policy; import com.cedarpolicy.model.entity.Entity; import com.cedarpolicy.value.EntityTypeName; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.json.JSONException; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; import java.util.HashSet; import java.nio.charset.StandardCharsets; @@ -34,11 +40,92 @@ import java.util.HashMap; import java.util.Set; +import static com.cedarpolicy.CedarJson.objectWriter; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; + /** Utils to help with tests. */ public final class TestUtil { + /** The escape sequence Cedar uses for an entity reference nested inside a value. */ + private static final String ENTITY_ESCAPE_SEQ = "__entity"; + private TestUtil() { } + /** + * Assert that an object serializes to the expected JSON. Compared semantically rather than as strings: neither the + * order of an object's fields nor the order of an array's elements is part of Cedar's encoding, and both are in fact + * unspecified here, because parents are serialized from a {@code Set} and attributes and tags from an immutable map + * whose iteration order varies between JVM runs. + * + * @param expectedJSON The expected encoding. + * @param obj The object to serialize. + */ + public static void assertJSONEqual(JsonNode expectedJSON, Object obj) { + String objJson = assertDoesNotThrow(() -> objectWriter().writeValueAsString(obj)); + try { + JSONAssert.assertEquals(expectedJSON.toString(), objJson, JSONCompareMode.NON_EXTENSIBLE); + } catch (JSONException e) { + throw new AssertionError("Failed to compare JSON: " + e.getMessage(), e); + } + } + + /** + * Assert that an exception's message mentions every one of the given fragments. Checking several fragments rather + * than one keeps the assertion specific enough to distinguish the rule that was violated, while still tolerating + * rewording elsewhere in the message. + * + * @param e The exception to inspect. + * @param fragments The fragments the message must contain. + */ + public static void assertMessageContains(Throwable e, String... fragments) { + for (String fragment : fragments) { + assertTrue(e.getMessage() != null && e.getMessage().contains(fragment), + "Expected the error to mention '%s' but was: '%s'".formatted(fragment, e.getMessage())); + } + } + + /** + * Build an entity UID in the escaped {@code __entity} form. + * + * @param type The entity type name. + * @param id The entity id. + * @return The encoded UID. + */ + public static ObjectNode buildEuidObject(String type, String id) { + var n = JsonNodeFactory.instance.objectNode(); + var inner = JsonNodeFactory.instance.objectNode(); + inner.put("id", id); + inner.put("type", type); + n.replace(ENTITY_ESCAPE_SEQ, inner); + return n; + } + + /** + * Build a partial entity UID whose id is unknown. + * + * @param type The entity type name. + * @return The encoded UID. + */ + public static ObjectNode buildUidObject(String type) { + var n = JsonNodeFactory.instance.objectNode(); + n.put("type", type); + return n; + } + + /** + * Build an entity UID in the bare {@code {"type", "id"}} form. + * + * @param type The entity type name. + * @param id The entity id. + * @return The encoded UID. + */ + public static ObjectNode buildUidObject(String type, String id) { + var n = buildUidObject(type); + n.put("id", id); + return n; + } + /** * Load schema file. * diff --git a/CedarJava/src/test/resources/tpe_schema.cedarschema b/CedarJava/src/test/resources/tpe_schema.cedarschema new file mode 100644 index 00000000..fb4b5ffd --- /dev/null +++ b/CedarJava/src/test/resources/tpe_schema.cedarschema @@ -0,0 +1,15 @@ +entity Group; + +entity User in [Group] { + department?: String, +} tags String; + +entity Photo; + +action view appliesTo { + principal: [User], + resource: [Photo], + context: { + authenticated?: Bool, + } +}; diff --git a/CedarJava/src/test/resources/tpe_schema.json b/CedarJava/src/test/resources/tpe_schema.json new file mode 100644 index 00000000..a856c415 --- /dev/null +++ b/CedarJava/src/test/resources/tpe_schema.json @@ -0,0 +1,32 @@ +{ + "": { + "entityTypes": { + "Group": {}, + "User": { + "memberOfTypes": ["Group"], + "shape": { + "type": "Record", + "attributes": { + "department": { "type": "String", "required": false } + } + }, + "tags": { "type": "String" } + }, + "Photo": {} + }, + "actions": { + "view": { + "appliesTo": { + "principalTypes": ["User"], + "resourceTypes": ["Photo"], + "context": { + "type": "Record", + "attributes": { + "authenticated": { "type": "Boolean", "required": false } + } + } + } + } + } + } +} diff --git a/CedarJavaFFI/Cargo.toml b/CedarJavaFFI/Cargo.toml index 570c070c..aea20ba3 100644 --- a/CedarJavaFFI/Cargo.toml +++ b/CedarJavaFFI/Cargo.toml @@ -22,6 +22,7 @@ jni_fn = "0.1.0" [features] partial-eval = ["cedar-policy/partial-eval"] +tpe = ["cedar-policy/tpe"] [dev-dependencies] cool_asserts = "2.0" diff --git a/CedarJavaFFI/src/interface.rs b/CedarJavaFFI/src/interface.rs index 2ace7222..731e3f17 100644 --- a/CedarJavaFFI/src/interface.rs +++ b/CedarJavaFFI/src/interface.rs @@ -46,6 +46,7 @@ use crate::{ jmap::Map, jset::Set, objects::{JEntityId, JEntityTypeName, JEntityUID, JLinkValue, JPolicy, JTemplateLink, Object}, + tpe::{validate_partial_entities, validate_partial_entity}, utils::raise_npe, }; use crate::{helpers::validate_with_level_json_str, objects::JFormatterConfig}; @@ -1250,6 +1251,64 @@ pub fn get_json_schema_internal<'a>( } } +/// Public string-based JSON interface to validate a partial entity against a schema +#[jni_fn("com.cedarpolicy.model.entity.PartialEntity")] +pub fn validatePartialEntityJni<'a>( + mut env: JNIEnv<'a>, + _: JClass, + entity_jstr: JString<'a>, + schema_jstr: JString<'a>, +) -> jvalue { + match validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr) { + Ok(v) => v.as_jni(), + Err(e) => jni_failed(&mut env, e.as_ref()), + } +} + +fn validate_partial_entity_internal<'a>( + env: &mut JNIEnv<'a>, + entity_jstr: JString<'a>, + schema_jstr: JString<'a>, +) -> Result> { + if entity_jstr.is_null() || schema_jstr.is_null() { + raise_npe(env) + } else { + let entity_json = String::from(env.get_string(&entity_jstr)?); + let schema_json = String::from(env.get_string(&schema_jstr)?); + validate_partial_entity(&entity_json, &schema_json)?; + Ok(JValueGen::Object(env.new_string("success")?.into())) + } +} + +/// Public string-based JSON interface to validate a collection of partial entities against a schema +#[jni_fn("com.cedarpolicy.model.entity.PartialEntities")] +pub fn validatePartialEntitiesJni<'a>( + mut env: JNIEnv<'a>, + _: JClass, + entities_jstr: JString<'a>, + schema_jstr: JString<'a>, +) -> jvalue { + match validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr) { + Ok(v) => v.as_jni(), + Err(e) => jni_failed(&mut env, e.as_ref()), + } +} + +fn validate_partial_entities_internal<'a>( + env: &mut JNIEnv<'a>, + entities_jstr: JString<'a>, + schema_jstr: JString<'a>, +) -> Result> { + if entities_jstr.is_null() || schema_jstr.is_null() { + raise_npe(env) + } else { + let entities_json = String::from(env.get_string(&entities_jstr)?); + let schema_json = String::from(env.get_string(&schema_jstr)?); + validate_partial_entities(&entities_json, &schema_json)?; + Ok(JValueGen::Object(env.new_string("success")?.into())) + } +} + #[cfg(test)] pub(crate) mod jvm_based_tests { use super::*; @@ -1260,6 +1319,163 @@ pub(crate) mod jvm_based_tests { pub(crate) static JVM: LazyLock = LazyLock::new(|| create_jvm().unwrap()); // Static JVM to be used by all the tests. LazyLock for thread-safe lazy initialization + #[cfg(feature = "tpe")] + mod tpe_tests { + use super::*; + + const SCHEMA: &str = r#" + entity Group; + entity User in [Group] = { "isAdmin": Bool }; + entity Photo; + action view appliesTo { + principal: [User], + resource: [Photo], + context: { "authenticated": Bool } + }; + "#; + + fn schema_json() -> String { + serde_json::json!(SCHEMA).to_string() + } + + /// Read back the string an `_internal` returned on success. + #[track_caller] + fn success_string<'a>(env: &mut JNIEnv<'a>, result: JValueOwned<'a>) -> String { + let jstr = JString::cast(env, result.l().unwrap()).unwrap(); + String::from(env.get_string(&jstr).unwrap()) + } + + /// Assert that validation failed for the expected reason. Errors crossing this boundary are + /// flattened to a `Box`, so `is_err()` alone would also match a schema that + /// failed to parse or a payload that failed to deserialize. + #[track_caller] + fn assert_err_contains(result: Result>, fragments: &[&str]) { + let err = match result { + Ok(_) => panic!("expected validation to fail"), + Err(e) => e.to_string(), + }; + for fragment in fragments { + assert!( + err.contains(fragment), + "expected the error to mention `{fragment}` but was: {err}" + ); + } + } + + #[test] + fn validate_partial_entity_internal_success() { + let mut env = JVM.attach_current_thread().unwrap(); + let entity = serde_json::json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false } + }); + let entity_jstr = env.new_string(entity.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + let result = + validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr).unwrap(); + assert_eq!(success_string(&mut env, result), "success"); + assert!(!env.exception_check().unwrap()); + } + + #[test] + fn validate_partial_entity_internal_mistyped_attr() { + let mut env = JVM.attach_current_thread().unwrap(); + let entity = serde_json::json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": 3 } + }); + let entity_jstr = env.new_string(entity.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + assert_err_contains( + validate_partial_entity_internal(&mut env, entity_jstr, schema_jstr), + &[ + "attribute `isAdmin`", + "User::\"alice\"", + "type mismatch", + "expected to have type bool", + "actually has type long", + ], + ); + } + + #[test] + fn validate_partial_entity_internal_null() { + let mut env = JVM.attach_current_thread().unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + let result = validate_partial_entity_internal( + &mut env, + JString::from(JObject::null()), + schema_jstr, + ); + assert!( + result.is_ok(), + "a null input is reported to Java, not to us" + ); + assert!( + env.exception_check().unwrap(), + "Expected java exception due to a null input" + ); + env.exception_clear().unwrap(); + } + + #[test] + fn validate_partial_entities_internal_success() { + let mut env = JVM.attach_current_thread().unwrap(); + let entities = serde_json::json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [ { "type": "Group", "id": "admins" } ] + } + ]); + let entities_jstr = env.new_string(entities.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + let result = + validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr).unwrap(); + assert_eq!(success_string(&mut env, result), "success"); + assert!(!env.exception_check().unwrap()); + } + + #[test] + fn validate_partial_entities_internal_duplicate_uid() { + let mut env = JVM.attach_current_thread().unwrap(); + let entities = serde_json::json!([ + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": false }, "parents": [] }, + { "uid": { "type": "User", "id": "alice" }, "attrs": { "isAdmin": true }, "parents": [] } + ]); + let entities_jstr = env.new_string(entities.to_string()).unwrap(); + let schema_jstr = env.new_string(schema_json()).unwrap(); + + assert_err_contains( + validate_partial_entities_internal(&mut env, entities_jstr, schema_jstr), + &["duplicate entity entry", "User::\"alice\""], + ); + } + + #[test] + fn validate_partial_entities_internal_null_schema() { + let mut env = JVM.attach_current_thread().unwrap(); + let entities_jstr = env.new_string("[]").unwrap(); + let result = validate_partial_entities_internal( + &mut env, + entities_jstr, + JString::from(JObject::null()), + ); + assert!( + result.is_ok(), + "a null input is reported to Java, not to us" + ); + assert!( + env.exception_check().unwrap(), + "Expected java exception due to a null input" + ); + env.exception_clear().unwrap(); + } + } + mod policy_tests { use super::*; diff --git a/CedarJavaFFI/src/lib.rs b/CedarJavaFFI/src/lib.rs index c1cd54a5..b1832977 100644 --- a/CedarJavaFFI/src/lib.rs +++ b/CedarJavaFFI/src/lib.rs @@ -24,5 +24,6 @@ mod jset; mod jvm_test_utils; mod objects; mod tests; +mod tpe; mod utils; pub use interface::*; diff --git a/CedarJavaFFI/src/tests.rs b/CedarJavaFFI/src/tests.rs index 2a905a1a..acec52f2 100644 --- a/CedarJavaFFI/src/tests.rs +++ b/CedarJavaFFI/src/tests.rs @@ -1035,4 +1035,143 @@ mod partial_authorization_tests { } } +#[cfg(feature = "tpe")] +mod tpe_validation_tests { + use super::*; + use crate::tpe::{validate_partial_entities, validate_partial_entity}; + use serde_json::json; + + const SCHEMA: &str = r#" + entity Group; + entity User in [Group] = { "isAdmin": Bool }; + entity Photo; + action view appliesTo { + principal: [User], + resource: [Photo], + context: { "authenticated": Bool } + }; + "#; + + fn schema_json() -> String { + json!(SCHEMA).to_string() + } + + /// Assert that validation failed for the expected reason. Checking several fragments of the + /// message rather than only that an error occurred keeps the assertion specific to the rule + /// under test: every error crossing this boundary is flattened to a `Box`, so + /// `Err(_)` alone would also match a schema that failed to parse or JSON that failed to + /// deserialize. + #[track_caller] + fn assert_err_contains(result: crate::utils::Result<()>, fragments: &[&str]) { + let err = result.expect_err("expected validation to fail").to_string(); + for fragment in fragments { + assert!( + err.contains(fragment), + "expected the error to mention `{fragment}` but was: {err}" + ); + } + } + + #[test] + fn validate_partial_entity_succeeds() { + let entity = json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false } + }); + assert_matches!( + validate_partial_entity(&entity.to_string(), &schema_json()), + Ok(()) + ); + } + + #[test] + fn validate_partial_entity_with_mistyped_attr_fails() { + let entity = json!({ + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": 3 } + }); + assert_err_contains( + validate_partial_entity(&entity.to_string(), &schema_json()), + &[ + "attribute `isAdmin`", + "User::\"alice\"", + "type mismatch", + "expected to have type bool", + "actually has type long", + ], + ); + } + + #[test] + fn validate_partial_entity_with_undeclared_type_fails() { + let entity = json!({ "uid": { "type": "Album", "id": "trip" } }); + assert_err_contains( + validate_partial_entity(&entity.to_string(), &schema_json()), + &[ + "entity `Album::\"trip\"`", + "type `Album`", + "not declared in the schema", + ], + ); + } + + #[test] + fn validate_partial_entities_with_unknown_ancestors_of_parent_fails() { + let entities = json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [ { "type": "Group", "id": "admins" } ] + }, + { + "uid": { "type": "Group", "id": "admins" }, + "attrs": {} + } + ]); + assert_err_contains( + validate_partial_entities(&entities.to_string(), &schema_json()), + &[ + "ancestor `Group::\"admins\"`", + "of `User::\"alice\"`", + "has unknown ancestors", + ], + ); + } + + #[test] + fn validate_partial_entities_with_absent_parent_succeeds() { + let entities = json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [ { "type": "Group", "id": "admins" } ] + } + ]); + assert_matches!( + validate_partial_entities(&entities.to_string(), &schema_json()), + Ok(()) + ); + } + + #[test] + fn validate_partial_entities_with_duplicate_uid_fails() { + let entities = json!([ + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": false }, + "parents": [] + }, + { + "uid": { "type": "User", "id": "alice" }, + "attrs": { "isAdmin": true }, + "parents": [] + } + ]); + assert_err_contains( + validate_partial_entities(&entities.to_string(), &schema_json()), + &["duplicate entity entry", "User::\"alice\""], + ); + } +} + mod parsing_tests {} diff --git a/CedarJavaFFI/src/tpe.rs b/CedarJavaFFI/src/tpe.rs new file mode 100644 index 00000000..183c9c04 --- /dev/null +++ b/CedarJavaFFI/src/tpe.rs @@ -0,0 +1,69 @@ +/* + * 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. + */ + +//! Validation helpers for type-aware partial evaluation inputs. + +#[cfg(feature = "tpe")] +use cedar_policy::{ffi::Schema as FFISchema, PartialEntities, Schema}; +#[cfg(feature = "tpe")] +use serde_json::Value; + +use crate::utils::Result; + +/// Error message returned when this library was built without the `tpe` feature. +/// The Java layer matches on the leading token to raise +/// `MissingExperimentalFeatureException`. +#[cfg(not(feature = "tpe"))] +const TPE_DISABLED: &str = + "TypeAwarePartialEvaluationNotEnabled: the `tpe` feature is disabled in this build"; + +#[cfg(feature = "tpe")] +fn parse_schema_str(schema_json: &str) -> Result { + match serde_json::from_str(schema_json)? { + FFISchema::Cedar(src) => Ok(Schema::from_cedarschema_str(&src)?.0), + FFISchema::Json(json) => Ok(Schema::from_json_value(json.into())?), + } +} + +/// Validates a single partial entity against a schema. Note that this cannot +/// detect problems that span multiple entities, such as an ancestor with +/// unknown ancestors. +#[cfg(feature = "tpe")] +pub fn validate_partial_entity(entity_json: &str, schema_json: &str) -> Result<()> { + let schema = parse_schema_str(schema_json)?; + let entity: Value = serde_json::from_str(entity_json)?; + PartialEntities::from_json_value(Value::Array(vec![entity]), &schema)?; + Ok(()) +} + +/// Validates a collection of partial entities against a schema. +#[cfg(feature = "tpe")] +pub fn validate_partial_entities(entities_json: &str, schema_json: &str) -> Result<()> { + let schema = parse_schema_str(schema_json)?; + let entities: Value = serde_json::from_str(entities_json)?; + PartialEntities::from_json_value(entities, &schema)?; + Ok(()) +} + +#[cfg(not(feature = "tpe"))] +pub fn validate_partial_entity(_entity_json: &str, _schema_json: &str) -> Result<()> { + Err(TPE_DISABLED.into()) +} + +#[cfg(not(feature = "tpe"))] +pub fn validate_partial_entities(_entities_json: &str, _schema_json: &str) -> Result<()> { + Err(TPE_DISABLED.into()) +}