diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java index 8b0b9d47b7..b715b0964d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java @@ -1313,7 +1313,17 @@ public Collection getIdAnnotations(Object value) { return null; } - AccessibleObject idField = getEntityBinding(getType(value)).getIdField(); + return getIdAnnotations(getType(value)); + } + + /** + * Returns annotations applied to the ID field. + * + * @param type the type + * @return Collection of Annotations + */ + public Collection getIdAnnotations(Type type) { + AccessibleObject idField = getEntityBinding(type).getIdField(); if (idField != null) { return Arrays.asList(idField.getDeclaredAnnotations()); } @@ -1321,6 +1331,24 @@ public Collection getIdAnnotations(Object value) { return Collections.emptyList(); } + /** + * Searches for a specific annotation on the ID field. + * + * @param the annotation type to search for + * @param recordClass the record type + * @param annotationClass the annotation to search for + * @return + */ + public A getIdAnnotation(Type recordClass, Class annotationClass) { + Collection annotations = getIdAnnotations(recordClass); + for (Annotation annotation : annotations) { + if (annotation.annotationType().equals(annotationClass)) { + return annotationClass.cast(annotation); + } + } + return null; + } + /** * Follow for this class or super-class for JPA {@link Entity} annotation. * diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java index 29a0984320..f967b49f55 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java @@ -22,8 +22,13 @@ import static com.yahoo.elide.modelconfig.model.Type.TEXT; import static com.yahoo.elide.modelconfig.model.Type.TIME; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.core.type.Field; import com.yahoo.elide.core.type.Method; import com.yahoo.elide.core.type.Package; @@ -392,7 +397,12 @@ public ArgumentDefinition[] arguments() { return getArgumentDefinitions(table.getArguments()); } }); + putPermissionAnnotations(table, annotations); + return annotations; + } + private static void putPermissionAnnotations(Table table, + Map, Annotation> annotations) { String readPermission = table.getReadAccess(); if (StringUtils.isNotEmpty(readPermission)) { annotations.put(ReadPermission.class, new ReadPermission() { @@ -408,7 +418,45 @@ public String expression() { } }); } - return annotations; + + annotations.put(CreatePermission.class, new CreatePermission() { + + @Override + public Class annotationType() { + return CreatePermission.class; + } + + @Override + public String expression() { + return Role.NONE_ROLE; + } + }); + + annotations.put(UpdatePermission.class, new UpdatePermission() { + + @Override + public Class annotationType() { + return UpdatePermission.class; + } + + @Override + public String expression() { + return Role.NONE_ROLE; + } + }); + + annotations.put(DeletePermission.class, new DeletePermission() { + + @Override + public Class annotationType() { + return DeletePermission.class; + } + + @Override + public String expression() { + return Role.NONE_ROLE; + } + }); } private static ArgumentDefinition[] getArgumentDefinitions(List arguments) { @@ -857,6 +905,14 @@ public CardinalitySize size() { } }); + // This disables get by id + annotations.put(Exclude.class, new Exclude() { + @Override + public Class annotationType() { + return Exclude.class; + } + }); + return new FieldType("id", LONG_TYPE, annotations); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java index c5eec04e4c..fac6b005cf 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java @@ -2137,7 +2137,7 @@ public void testUpsertWithDynamicModel() throws IOException { ) ).toGraphQLSpec(); - String expected = "Invalid operation: SalesNamespace_orderDetails is read only."; + String expected = "Invalid operation: UPSERT is not permitted on SalesNamespace_orderDetails."; runQueryWithExpectedError(graphQLRequest, expected); } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java index 03ae1d4ba9..e52adcfc10 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java @@ -41,6 +41,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; /** @@ -60,6 +62,7 @@ public class GraphQLConversionUtils { private final Map outputConversions = new HashMap<>(); private final Map inputConversions = new HashMap<>(); private final Map enumConversions = new HashMap<>(); + private final Map namedEnumConversions = new HashMap<>(); private final Map mapConversions = new HashMap<>(); private final GraphQLNameUtils nameUtils; @@ -134,11 +137,11 @@ public GraphQLEnumType classToEnumType(Type enumClazz) { return enumConversions.get(enumClazz); } - Enum [] values = (Enum []) enumClazz.getEnumConstants(); + Enum [] values = (Enum []) enumClazz.getEnumConstants(); GraphQLEnumType.Builder builder = newEnum().name(nameUtils.toOutputTypeName(enumClazz)); - for (Enum value : values) { + for (Enum value : values) { builder.value(value.toString(), value); } @@ -149,6 +152,40 @@ public GraphQLEnumType classToEnumType(Type enumClazz) { return enumResult; } + /** + * Converts an enum to a GraphQLEnumType where the enum needs to be filtered and + * will be identified by its name and not enum type as it is not possible to + * define a subset of values of an existing GraphQLEnum Type. + * + * @param enumClazz the Enum to convert + * @param nameProcessor to determine the enum name + * @param filter the predicate to filter the enum values + * @return A GraphQLEnum type for class. + */ + public GraphQLEnumType classToNamedEnumType(Type enumClazz, Function nameProcessor, + Predicate> filter) { + String name = nameProcessor.apply(nameUtils.toOutputTypeName(enumClazz)); + if (namedEnumConversions.containsKey(name)) { + return namedEnumConversions.get(name); + } + + Enum [] values = (Enum []) enumClazz.getEnumConstants(); + + GraphQLEnumType.Builder builder = newEnum().name(name); + + for (Enum value : values) { + if (filter.test(value)) { + builder.value(value.toString(), value); + } + } + + GraphQLEnumType enumResult = builder.build(); + + namedEnumConversions.put(name, enumResult); + + return enumResult; + } + /** * Creates a GraphQL Map Query type. GraphQL doesn't support this natively. We mimic * maps by creating a list of key/value pairs. diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java index 2b29c8b281..47d3dff30c 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java @@ -12,11 +12,17 @@ import static graphql.schema.GraphQLObjectType.newObject; import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.apollographql.federation.graphqljava.Federation; +import org.antlr.v4.runtime.tree.ParseTree; import org.apache.commons.collections4.CollectionUtils; import graphql.Scalars; @@ -24,6 +30,7 @@ import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLArgument; import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLInputObjectType; import graphql.schema.GraphQLInputType; @@ -33,10 +40,14 @@ import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLType; import graphql.schema.GraphQLTypeReference; +import lombok.Builder; +import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.lang.annotation.Annotation; import java.util.HashMap; import java.util.HashSet; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -58,8 +69,7 @@ public class ModelBuilder { public static final String OBJECT_QUERY = "Query"; private EntityDictionary entityDictionary; - private DataFetcher dataFetcher; - private GraphQLArgument relationshipOpArg; + private DataFetcher dataFetcher; private GraphQLArgument idArgument; private GraphQLArgument filterArgument; private GraphQLArgument pageOffsetArgument; @@ -76,8 +86,17 @@ public class ModelBuilder { private Set> excludedEntities; private Set objectTypes; + private Map relationshipOpArgument; + private boolean enableFederation; + @Builder + @Data + public static class RelationshipOpKey { + private final Type entity; + private final String field; + } + /** * Class constructor, constructs the custom arguments to handle mutations. * @param entityDictionary elide entity dictionary @@ -87,7 +106,7 @@ public class ModelBuilder { public ModelBuilder(EntityDictionary entityDictionary, NonEntityDictionary nonEntityDictionary, ElideSettings settings, - DataFetcher dataFetcher, String apiVersion) { + DataFetcher dataFetcher, String apiVersion) { objectTypes = new HashSet<>(); this.generator = new GraphQLConversionUtils(entityDictionary, nonEntityDictionary); @@ -97,12 +116,6 @@ public ModelBuilder(EntityDictionary entityDictionary, this.apiVersion = apiVersion; this.enableFederation = settings.isEnableGraphQLFederation(); - relationshipOpArg = newArgument() - .name(ARGUMENT_OPERATION) - .type(generator.classToEnumType(ClassType.of(RelationshipOp.class))) - .defaultValue(RelationshipOp.FETCH) - .build(); - idArgument = newArgument() .name(ARGUMENT_IDS) .type(new GraphQLList(Scalars.GraphQLString)) @@ -150,12 +163,123 @@ public ModelBuilder(EntityDictionary entityDictionary, queryObjectRegistry = new HashMap<>(); connectionObjectRegistry = new HashMap<>(); excludedEntities = new HashSet<>(); + relationshipOpArgument = new HashMap<>(); } public void withExcludedEntities(Set> excludedEntities) { this.excludedEntities = excludedEntities; } + /** + * Gets the relationship op for a root entity. + * + * @param entityClass the entity class + * @return the relationship op + */ + public GraphQLArgument getRelationshipOp(Type entityClass) { + RelationshipOpKey key = RelationshipOpKey.builder().entity(entityClass).build(); + GraphQLArgument existing = relationshipOpArgument.get(key); + if (existing != null) { + return existing; + } + + String entityName = entityDictionary.getJsonAliasFor(entityClass); + String postfix = entityName.substring(0, 1).toUpperCase(Locale.ENGLISH) + entityName.substring(1); + GraphQLEnumType relationshipOp = generator.classToNamedEnumType(ClassType.of(RelationshipOp.class), + name -> name + postfix, e -> { + RelationshipOp op = RelationshipOp.valueOf(e.name()); + switch (op) { + case FETCH: + return canRead(entityClass); + case DELETE: + return canDelete(entityClass); + case UPSERT: + return canCreate(entityClass); + case REPLACE: + return canCreate(entityClass) || canUpdate(entityClass) || canDelete(entityClass); + case REMOVE: + return canDelete(entityClass); + case UPDATE: + return canUpdate(entityClass); + } + throw new IllegalArgumentException("Unsupported enum value " + e.toString()); + }); + + GraphQLArgument result = buildRelationshipOpArgument(relationshipOp); + relationshipOpArgument.put(key, result); + return result; + } + + /** + * Gets the relationship op for a relationship. + * + * @param entityClass the entity class + * @param field the field + * @param relationshipClass the relationship class + * @return the relationship op + */ + public GraphQLArgument getRelationshipOp(Type entityClass, String field, Type relationshipClass) { + RelationshipOpKey key = RelationshipOpKey.builder().entity(entityClass).field(field).build(); + GraphQLArgument existing = relationshipOpArgument.get(key); + if (existing != null) { + return existing; + } + + String entityName = entityDictionary.getJsonAliasFor(entityClass); + String postfix = entityName.substring(0, 1).toUpperCase(Locale.ENGLISH) + entityName.substring(1) + + field.substring(0, 1).toUpperCase(Locale.ENGLISH) + field.substring(1); + GraphQLEnumType relationshipOp = generator.classToNamedEnumType(ClassType.of(RelationshipOp.class), + name -> name + postfix, e -> { + RelationshipOp op = RelationshipOp.valueOf(e.name()); + switch (op) { + case FETCH: + return canRead(entityClass, field); + case DELETE: + return canDelete(relationshipClass); + case UPSERT: + return canUpdate(entityClass, field); + case REPLACE: + return canUpdate(entityClass, field); + case REMOVE: + return canUpdate(entityClass, field); + case UPDATE: + return canUpdate(entityClass, field); + } + throw new IllegalArgumentException("Unsupported enum value " + e.toString()); + }); + + GraphQLArgument result = buildRelationshipOpArgument(relationshipOp); + relationshipOpArgument.put(key, result); + return result; + } + + /** + * Builds the relationship op argument given the relationship op enum. + * + * @param relationshipOp the relationship op enum + * @return the argument + */ + private GraphQLArgument buildRelationshipOpArgument(GraphQLEnumType relationshipOp) { + Object defaultValue = null; + if (!relationshipOp.getValues().isEmpty()) { + if (relationshipOp.getValue(RelationshipOp.FETCH.name()) != null) { + defaultValue = relationshipOp.getValue(RelationshipOp.FETCH.name()).getValue(); + } else { + defaultValue = null; + } + } else { + // No operations + return null; + } + + return newArgument() + .name(ARGUMENT_OPERATION) + .type(relationshipOp) + .defaultValueProgrammatic(defaultValue) + .build(); + } + + /** * Builds a GraphQL schema. * @return The built schema. @@ -178,18 +302,22 @@ public GraphQLSchema build() { GraphQLObjectType.Builder root = newObject().name(OBJECT_QUERY); for (Type clazz : rootClasses) { String entityName = entityDictionary.getJsonAliasFor(clazz); - root.field(newFieldDefinition() - .name(entityName) - .description(EntityDictionary.getEntityDescription(clazz)) - .argument(relationshipOpArg) - .argument(idArgument) - .argument(filterArgument) - .argument(sortArgument) - .argument(pageFirstArgument) - .argument(pageOffsetArgument) - .argument(buildInputObjectArgument(clazz, true)) - .arguments(generator.entityArgumentToQueryObject(clazz, entityDictionary)) - .type(buildConnectionObject(clazz))); + + GraphQLArgument relationshipOpArg = getRelationshipOp(clazz); + if (relationshipOpArg != null) { + root.field(newFieldDefinition() + .name(entityName) + .description(EntityDictionary.getEntityDescription(clazz)) + .argument(relationshipOpArg) + .argument(idArgument) + .argument(filterArgument) + .argument(sortArgument) + .argument(pageFirstArgument) + .argument(pageOffsetArgument) + .argument(buildInputObjectArgument(clazz, true)) + .arguments(generator.entityArgumentToQueryObject(clazz, entityDictionary)) + .type(buildConnectionObject(clazz))); + } } @@ -318,28 +446,26 @@ private GraphQLObjectType buildQueryObject(Type entityClass) { String relationshipEntityName = nameUtils.toConnectionName(relationshipClass); RelationshipType type = entityDictionary.getRelationshipType(entityClass, relationship); - - if (type.isToOne()) { - builder.field(newFieldDefinition() - .name(relationship) - .argument(relationshipOpArg) - .argument(buildInputObjectArgument(relationshipClass, false)) - .arguments(generator.entityArgumentToQueryObject(relationshipClass, entityDictionary)) - .type(new GraphQLTypeReference(relationshipEntityName)) - ); - } else { - builder.field(newFieldDefinition() - .name(relationship) - .argument(relationshipOpArg) - .argument(filterArgument) - .argument(sortArgument) - .argument(pageOffsetArgument) - .argument(pageFirstArgument) - .argument(idArgument) - .argument(buildInputObjectArgument(relationshipClass, true)) - .arguments(generator.entityArgumentToQueryObject(relationshipClass, entityDictionary)) - .type(new GraphQLTypeReference(relationshipEntityName)) - ); + GraphQLArgument relationshipOpArg = getRelationshipOp(entityClass, relationship, relationshipClass); + if (relationshipOpArg != null) { + if (type.isToOne()) { + builder.field(newFieldDefinition().name(relationship) + .argument(relationshipOpArg) + .argument(buildInputObjectArgument(relationshipClass, false)) + .arguments(generator.entityArgumentToQueryObject(relationshipClass, entityDictionary)) + .type(new GraphQLTypeReference(relationshipEntityName))); + } else { + builder.field(newFieldDefinition().name(relationship) + .argument(relationshipOpArg) + .argument(filterArgument) + .argument(sortArgument) + .argument(pageOffsetArgument) + .argument(pageFirstArgument) + .argument(idArgument) + .argument(buildInputObjectArgument(relationshipClass, true)) + .arguments(generator.entityArgumentToQueryObject(relationshipClass, entityDictionary)) + .type(new GraphQLTypeReference(relationshipEntityName))); + } } } @@ -449,4 +575,140 @@ private GraphQLInputType buildInputObjectStub(Type clazz) { inputObjectRegistry.put(clazz, constructed); return constructed; } + + protected boolean isNone(String permission) { + return "Prefab.Role.None".equalsIgnoreCase(permission) || Role.NONE_ROLE.equalsIgnoreCase(permission); + } + + protected boolean canCreate(Type type) { + return !isNone(getCreatePermission(type)); + } + + protected boolean canRead(Type type) { + return !isNone(getReadPermission(type)); + } + + protected boolean canUpdate(Type type) { + return !isNone(getUpdatePermission(type)); + } + + protected boolean canDelete(Type type) { + return !isNone(getDeletePermission(type)); + } + + protected boolean canCreate(Type type, String field) { + return !isNone(getCreatePermission(type, field)); + } + + protected boolean canRead(Type type, String field) { + return !isNone(getReadPermission(type, field)); + } + + protected boolean canUpdate(Type type, String field) { + return !isNone(getUpdatePermission(type, field)); + } + + protected boolean canDelete(Type type, String field) { + return !isNone(getDeletePermission(type, field)); + } + + /** + * Get the calculated {@link CreatePermission} value for the entity. + * + * @param clazz the entity class + * @return the create permissions for an entity + */ + protected String getCreatePermission(Type clazz) { + return getPermission(clazz, CreatePermission.class); + } + + /** + * Get the calculated {@link ReadPermission} value for the entity. + * + * @param clazz the entity class + * @return the read permissions for an entity + */ + protected String getReadPermission(Type clazz) { + return getPermission(clazz, ReadPermission.class); + } + + /** + * Get the calculated {@link UpdatePermission} value for the entity. + * + * @param clazz the entity class + * @return the update permissions for an entity + */ + protected String getUpdatePermission(Type clazz) { + return getPermission(clazz, UpdatePermission.class); + } + + /** + * Get the calculated {@link DeletePermission} value for the entity. + * + * @param clazz the entity class + * @return the delete permissions for an entity + */ + protected String getDeletePermission(Type clazz) { + return getPermission(clazz, DeletePermission.class); + } + + /** + * Get the calculated {@link CreatePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the create permissions for the relationship + */ + protected String getCreatePermission(Type clazz, String field) { + return getPermission(clazz, field, CreatePermission.class); + } + + /** + * Get the calculated {@link ReadPermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the read permissions for the relationship + */ + protected String getReadPermission(Type clazz, String field) { + return getPermission(clazz, field, ReadPermission.class); + } + + /** + * Get the calculated {@link UpdatePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the update permissions for the relationship + */ + protected String getUpdatePermission(Type clazz, String field) { + return getPermission(clazz, field, UpdatePermission.class); + } + + /** + * Get the calculated {@link DeletePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the delete permissions for the relationship + */ + protected String getDeletePermission(Type clazz, String field) { + return getPermission(clazz, field, DeletePermission.class); + } + + protected String getPermission(Type clazz, Class permission) { + ParseTree parseTree = entityDictionary.getPermissionsForClass(clazz, permission); + if (parseTree != null) { + return parseTree.getText(); + } + return null; + } + + protected String getPermission(Type clazz, String field, Class permission) { + ParseTree parseTree = entityDictionary.getPermissionsForField(clazz, field, permission); + if (parseTree != null) { + return parseTree.getText(); + } + return null; + } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java index d168708ab7..f8f00a84e9 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java @@ -39,6 +39,8 @@ import graphql.execution.AsyncSerialExecutionStrategy; import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.SimpleDataFetcherExceptionHandler; +import graphql.validation.ValidationError; +import graphql.validation.ValidationErrorType; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.Getter; @@ -46,6 +48,7 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -320,7 +323,7 @@ private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper if (isMutation) { if (!result.getErrors().isEmpty()) { HashMap abortedResponseObject = new HashMap<>(); - abortedResponseObject.put("errors", result.getErrors()); + abortedResponseObject.put("errors", mapErrors(result.getErrors())); abortedResponseObject.put("data", null); // Do not commit. Throw OK response to process tx.close correctly. throw new GraphQLException(mapper.writeValueAsString(abortedResponseObject)); @@ -351,6 +354,42 @@ private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper } } + /** + * Generate more user friendly error messages. + * + * @param errors the errors to map + * @return the mapped errors + */ + private List mapErrors(List errors) { + List result = new ArrayList<>(errors.size()); + for (GraphQLError error : errors) { + if (error instanceof ValidationError validationError + && ValidationErrorType.WrongType.equals(validationError.getValidationErrorType())) { + if (validationError.getDescription().contains("ElideRelationshipOp")) { + String queryPath = String.join(" ", validationError.getQueryPath()); + RelationshipOp relationshipOp = Arrays.stream(RelationshipOp.values()) + .filter(op -> validationError.getDescription().contains(op.name())) + .findFirst() + .orElse(null); + if (relationshipOp != null) { + result.add(ValidationError.newValidationError() + .description("Invalid operation: " + relationshipOp.name() + " is not permitted on " + + queryPath + ".") + .extensions(validationError.getExtensions()) + .validationErrorType(validationError.getValidationErrorType()) + .sourceLocations(validationError.getLocations()) + .queryPath(validationError.getQueryPath()) + .build()); + continue; + } + } + } + result.add(error); + } + + return result; + } + public static ElideResponse handleNonRuntimeException( Elide elide, Exception error, diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java index ac81de77db..1602ffdf28 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java @@ -16,6 +16,11 @@ import static org.mockito.Mockito.mock; import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.dictionary.ArgumentType; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.request.Sorting; @@ -27,6 +32,8 @@ import example.Book; import example.Publisher; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import graphql.Scalars; import graphql.scalars.java.JavaPrimitives; @@ -39,9 +46,13 @@ import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import lombok.Getter; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -104,7 +115,7 @@ public ModelBuilderTest() { @Test public void testInternalModelConflict() { - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -128,7 +139,7 @@ public void testInternalModelConflict() { @Test public void testPageInfoObject() { - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -142,7 +153,7 @@ public void testPageInfoObject() { @Test public void testRelationshipParameters() { - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -181,7 +192,7 @@ public void testRelationshipParameters() { @Test public void testBuild() { - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -261,7 +272,7 @@ public void checkAttributeArguments() { arguments.add(new ArgumentType(TYPE, ClassType.STRING_TYPE)); dictionary.addArgumentsToAttribute(ClassType.of(Book.class), FIELD_PUBLISH_DATE, arguments); - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -282,7 +293,7 @@ public void checkModelArguments() { dictionary.addArgumentToEntity(ClassType.of(Publisher.class), new ArgumentType("filterPublisher", ClassType.STRING_TYPE)); dictionary.addArgumentToEntity(ClassType.of(Author.class), new ArgumentType("filterAuthor", ClassType.STRING_TYPE)); - DataFetcher fetcher = mock(DataFetcher.class); + DataFetcher fetcher = mock(DataFetcher.class); ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), @@ -308,6 +319,228 @@ public void checkModelArguments() { assertNotNull(authorField.getArgument("filterAuthor")); } + @Include + @CreatePermission(expression = "None") + public static class NoCreateEntity { + @Id + private Long id; + } + + @Include + @ReadPermission(expression = "None") + public static class NoReadEntity { + @Id + private Long id; + } + + @Include + @UpdatePermission(expression = "None") + public static class NoUpdateEntity { + @Id + private Long id; + } + + @Include + @DeletePermission(expression = "None") + @Getter + public static class NoDeleteEntity { + @Id + private Long id; + + private String name; + } + + @Include + @CreatePermission(expression = "None") + @UpdatePermission(expression = "None") + public static class NoUpsertEntity { + @Id + private Long id; + } + + @Include + @CreatePermission(expression = "None") + @UpdatePermission(expression = "None") + @DeletePermission(expression = "None") + public static class NoReplaceEntity { + @Id + private Long id; + } + + @Include + @CreatePermission(expression = "None") + @ReadPermission(expression = "None") + @UpdatePermission(expression = "None") + @DeletePermission(expression = "None") + public static class NoneEntity { + @Id + private Long id; + } + + enum EntityRelationshipOpInput { + NO_CREATE(NoCreateEntity.class, + List.of(RelationshipOp.DELETE, RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.FETCH), + List.of(RelationshipOp.UPSERT)), + NO_READ(NoReadEntity.class, + List.of(RelationshipOp.DELETE, RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT), + List.of(RelationshipOp.FETCH)), + NO_UPDATE(NoUpdateEntity.class, + List.of(RelationshipOp.FETCH, RelationshipOp.UPSERT, RelationshipOp.REPLACE, RelationshipOp.DELETE, + RelationshipOp.REMOVE), + List.of(RelationshipOp.UPDATE)), + NO_DELETE(NoDeleteEntity.class, + List.of(RelationshipOp.FETCH, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT), + List.of(RelationshipOp.REMOVE, RelationshipOp.DELETE)), + NO_UPSERT(NoUpsertEntity.class, + List.of(RelationshipOp.FETCH, RelationshipOp.REPLACE, RelationshipOp.DELETE, RelationshipOp.REMOVE), + List.of(RelationshipOp.UPDATE, RelationshipOp.UPSERT)), + NO_REPLACE(NoReplaceEntity.class, + List.of(RelationshipOp.FETCH), + List.of(RelationshipOp.REMOVE, RelationshipOp.UPDATE, RelationshipOp.UPSERT, RelationshipOp.DELETE, + RelationshipOp.REPLACE)), + NONE(NoneEntity.class, + List.of(), + List.of(RelationshipOp.FETCH, RelationshipOp.DELETE, RelationshipOp.REMOVE, RelationshipOp.REPLACE, + RelationshipOp.UPDATE, RelationshipOp.UPSERT)),; ; + EntityRelationshipOpInput(Class entity, List includes, List excludes) { + this.entity = entity; + this.includes = includes; + this.excludes = excludes; + } + + Class entity; + List includes; + List excludes; + } + + @ParameterizedTest + @EnumSource(EntityRelationshipOpInput.class) + void entityRelationshipOp(EntityRelationshipOpInput input) { + DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); // Make sure the schema has at least 1 entity + dictionary.bindEntity(input.entity); + ModelBuilder builder = new ModelBuilder(dictionary, + new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); + + GraphQLSchema schema = builder.build(); + String type = "ElideRelationshipOp" + input.entity.getSimpleName(); + if (schema.getType(type) instanceof GraphQLEnumType enumType) { + for (RelationshipOp include : input.includes) { + assertNotNull(enumType.getValue(include.name())); + } + for (RelationshipOp exclude : input.excludes) { + assertNull(enumType.getValue(exclude.name())); + } + + } + } + + @Include + @Getter + public static class RelatedEntity { + @Id + private Long id; + + private String name; + } + + @Include + @Getter + public static class RelationshipEntity { + @Id + private Long id; + + @ReadPermission(expression = "None") + @OneToMany + private List noFetch; + + @OneToMany + private List noDelete; + + @UpdatePermission(expression = "None") + @OneToMany + private List noRemove; + + @UpdatePermission(expression = "None") + @OneToMany + private List noReplace; + + @UpdatePermission(expression = "None") + @OneToMany + private List noUpdate; + } + + enum RelationshipOpInput { + NO_FETCH("RelationshipEntityNoFetch", + List.of(RelationshipOp.DELETE, RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT), + List.of(RelationshipOp.FETCH)), + NO_REMOVE("RelationshipEntityNoRemove", + List.of(RelationshipOp.FETCH, RelationshipOp.DELETE), + List.of(RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT)), + NO_UPSERT("RelationshipEntityNoUpsert", + List.of(RelationshipOp.FETCH, RelationshipOp.DELETE), + List.of(RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT)), + NO_REPLACE("RelationshipEntityNoReplace", + List.of(RelationshipOp.FETCH, RelationshipOp.DELETE), + List.of(RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT)), + NO_UPDATE("RelationshipEntityNoUpdate", + List.of(RelationshipOp.FETCH, RelationshipOp.DELETE), + List.of(RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT)), + NO_DELETE("RelationshipEntityNoDelete", + List.of(RelationshipOp.FETCH, RelationshipOp.REMOVE, RelationshipOp.REPLACE, RelationshipOp.UPDATE, + RelationshipOp.UPSERT), + List.of(RelationshipOp.DELETE)) + ; + + RelationshipOpInput(String name, List includes, List excludes) { + this.name = name; + this.includes = includes; + this.excludes = excludes; + } + + String name; + List includes; + List excludes; + } + + @ParameterizedTest + @EnumSource(RelationshipOpInput.class) + void relationshipOp(RelationshipOpInput input) { + DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(RelationshipEntity.class); + dictionary.bindEntity(RelatedEntity.class); + dictionary.bindEntity(NoDeleteEntity.class); + ModelBuilder builder = new ModelBuilder(dictionary, + new NonEntityDictionary(new DefaultClassScanner(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); + + GraphQLSchema schema = builder.build(); + String type = "ElideRelationshipOp" + input.name; + if (schema.getType(type) instanceof GraphQLEnumType enumType) { + for (RelationshipOp include : input.includes) { + assertNotNull(enumType.getValue(include.name())); + } + for (RelationshipOp exclude : input.excludes) { + assertNull(enumType.getValue(exclude.name())); + } + + } + } + + private GraphQLObjectType getConnectedType(GraphQLObjectType root, String connectionName) { GraphQLList edgesType = (GraphQLList) root.getFieldDefinition(EDGES).getType(); GraphQLObjectType rootType = (GraphQLObjectType) @@ -319,7 +552,7 @@ private GraphQLObjectType getConnectedType(GraphQLObjectType root, String connec } public static boolean validateEnum(Class expected, GraphQLEnumType actual) { - Enum [] values = (Enum []) expected.getEnumConstants(); + Enum [] values = (Enum []) expected.getEnumConstants(); Set enumNames = actual.getValues().stream() .map(GraphQLEnumValueDefinition::getName) .collect(Collectors.toSet()); diff --git a/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java b/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java index e9d6eb6964..4739c2fe2a 100644 --- a/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java +++ b/elide-swagger/src/main/java/com/yahoo/elide/swagger/OpenApiBuilder.java @@ -8,9 +8,15 @@ import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.RelationshipType; import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.swagger.converter.JsonApiModelResolver; @@ -19,6 +25,8 @@ import com.yahoo.elide.swagger.models.media.Relationship; import com.google.common.collect.Sets; +import org.antlr.v4.runtime.tree.ParseTree; + import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverterContextImpl; @@ -42,6 +50,7 @@ import lombok.Getter; +import java.lang.annotation.Annotation; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; @@ -248,36 +257,52 @@ public PathItem getRelationshipPath() { RelationshipType relationshipType = dictionary.getRelationshipType(parentClass, name); if (relationshipType.isToMany()) { - path.get(new Operation().tags(getTags()).description("Returns the relationship identifiers for " + name) - .responses(new ApiResponses().addApiResponse("200", okPluralResponse))); + if (canRead(parentClass, name) && canRead(type)) { + path.get(new Operation().tags(getTags()) + .description("Returns the relationship identifiers for " + name) + .responses(new ApiResponses().addApiResponse("200", okPluralResponse))); + } - path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Data(new Relationship(typeName)))))) - .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); - path.delete(new Operation().tags(getTags()).description("Deletes items from the relationship " + name) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Data(new Relationship(typeName)))))) - .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); - path.post(new Operation().tags(getTags()).description("Adds items to the relationship " + name) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Data(new Relationship(typeName)))))) - .responses(new ApiResponses().addApiResponse("201", okPluralResponse))); + if (canUpdate(parentClass, name)) { + path.post(new Operation().tags(getTags()).description("Adds items to the relationship " + name) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Data(new Relationship(typeName)))))) + .responses(new ApiResponses().addApiResponse("201", okPluralResponse))); + + path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Data(new Relationship(typeName)))))) + .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + + path.delete(new Operation().tags(getTags()) + .description("Deletes items from the relationship " + name) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Data(new Relationship(typeName)))))) + .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + } } else { - path.get(new Operation().tags(getTags()).description("Returns the relationship identifiers for " + name) - .responses(new ApiResponses().addApiResponse("200", okSingularResponse))); - path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Datum(new Relationship(typeName)))))) - .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); - } + if (canRead(parentClass, name) && canRead(type)) { + path.get(new Operation().tags(getTags()) + .description("Returns the relationship identifiers for " + name) + .responses(new ApiResponses().addApiResponse("200", okSingularResponse))); + } - for (Parameter param : getFilterParameters()) { - path.getGet().addParametersItem(param); + if (canUpdate(parentClass, name)) { + path.patch(new Operation().tags(getTags()).description("Replaces the relationship " + name) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Datum(new Relationship(typeName)))))) + .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + } } - for (Parameter param : getPageParameters()) { - path.getGet().addParametersItem(param); + if (path.getGet() != null) { + for (Parameter param : getFilterParameters()) { + path.getGet().addParametersItem(param); + } + + for (Parameter param : getPageParameters()) { + path.getGet().addParametersItem(param); + } } decorateGlobalResponses(path); @@ -305,12 +330,21 @@ public PathItem getCollectionPath() { String getDescription; String postDescription; + boolean canPost = false; + boolean canGet = false; if (lineage.isEmpty()) { getDescription = "Returns the collection of type " + typeName; postDescription = "Creates an item of type " + typeName; + + canGet = canRead(type); + canPost = canCreate(type); } else { getDescription = "Returns the relationship " + name; postDescription = "Creates an item of type " + typeName + " and adds it to " + name; + + Type parentClass = lineage.peek().getType(); + canGet = canRead(parentClass, name) && canRead(type); + canPost = canUpdate(parentClass, name) && canCreate(type); } List parameters = new ArrayList<>(); @@ -318,21 +352,25 @@ public PathItem getCollectionPath() { parameters.add(getSparseFieldsParameter()); getIncludeParameter().ifPresent(parameters::add); - path.get(new Operation().tags(getTags()).description(getDescription).parameters(parameters) - .responses(new ApiResponses().addApiResponse("200", okPluralResponse))); - - for (Parameter param : getFilterParameters()) { - path.getGet().addParametersItem(param); + if (canPost) { + path.post(new Operation().tags(getTags()).description(postDescription) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Datum(typeName))))) + .responses(new ApiResponses().addApiResponse("201", okSingularResponse))); } - for (Parameter param : getPageParameters()) { - path.getGet().addParametersItem(param); - } + if (canGet) { + path.get(new Operation().tags(getTags()).description(getDescription).parameters(parameters) + .responses(new ApiResponses().addApiResponse("200", okPluralResponse))); - path.post(new Operation().tags(getTags()).description(postDescription) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Datum(typeName))))) - .responses(new ApiResponses().addApiResponse("201", okSingularResponse))); + for (Parameter param : getFilterParameters()) { + path.getGet().addParametersItem(param); + } + + for (Parameter param : getPageParameters()) { + path.getGet().addParametersItem(param); + } + } decorateGlobalResponses(path); decorateGlobalParameters(path); @@ -361,17 +399,40 @@ public PathItem getInstancePath() { parameters.add(getSparseFieldsParameter()); getIncludeParameter().ifPresent(parameters::add); - path.get(new Operation().tags(getTags()).description("Returns an instance of type " + typeName) - .parameters(parameters) - .responses(new ApiResponses().addApiResponse("200", okSingularResponse))); + boolean canGet = false; + boolean canPatch = false; + boolean canDelete = false; - path.patch(new Operation().tags(getTags()).description("Modifies an instance of type " + typeName) - .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, - new MediaType().schema(new Datum(typeName))))) - .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + if (lineage.isEmpty()) { + // Root entity + canGet = canReadById(type); + canPatch = canUpdateById(type); + canDelete = canDeleteById(type); + } else { + // Relationship + Type parentClass = lineage.peek().getType(); + canGet = canRead(parentClass, name) && canReadById(type); + canPatch = canUpdate(parentClass, name); + canDelete = canUpdate(parentClass, name); + } + + if (canGet) { + path.get(new Operation().tags(getTags()).description("Returns an instance of type " + typeName) + .parameters(parameters) + .responses(new ApiResponses().addApiResponse("200", okSingularResponse))); + } + + if (canPatch) { + path.patch(new Operation().tags(getTags()).description("Modifies an instance of type " + typeName) + .requestBody(new RequestBody().content(new Content().addMediaType(JSONAPI_CONTENT_TYPE, + new MediaType().schema(new Datum(typeName))))) + .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + } - path.delete(new Operation().tags(getTags()).description("Deletes an instance of type " + typeName) - .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + if (canDelete) { + path.delete(new Operation().tags(getTags()).description("Deletes an instance of type " + typeName) + .responses(new ApiResponses().addApiResponse("204", okEmptyResponse))); + } decorateGlobalResponses(path); decorateGlobalParameters(path); @@ -883,4 +944,155 @@ protected Set find(Type rootClass) { } return paths; } + + protected boolean isNone(String permission) { + return "Prefab.Role.None".equalsIgnoreCase(permission) || Role.NONE_ROLE.equalsIgnoreCase(permission); + } + + protected boolean canCreate(Type type) { + return !isNone(getCreatePermission(type)); + } + + protected boolean canRead(Type type) { + return !isNone(getReadPermission(type)); + } + + protected boolean canUpdate(Type type) { + return !isNone(getUpdatePermission(type)); + } + + protected boolean canDelete(Type type) { + return !isNone(getDeletePermission(type)); + } + + protected boolean canReadById(Type type) { + boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; + return !(isNone(getReadPermission(type)) || excluded); + } + + protected boolean canUpdateById(Type type) { + boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; + return !(isNone(getUpdatePermission(type)) || excluded); + } + + protected boolean canDeleteById(Type type) { + boolean excluded = dictionary.getIdAnnotation(type, Exclude.class) != null; + return !(isNone(getDeletePermission(type)) || excluded); + } + + protected boolean canCreate(Type type, String field) { + return !isNone(getCreatePermission(type, field)); + } + + protected boolean canRead(Type type, String field) { + return !isNone(getReadPermission(type, field)); + } + + protected boolean canUpdate(Type type, String field) { + return !isNone(getUpdatePermission(type, field)); + } + + protected boolean canDelete(Type type, String field) { + return !isNone(getDeletePermission(type, field)); + } + + /** + * Get the calculated {@link CreatePermission} value for the entity. + * + * @param clazz the entity class + * @return the create permissions for an entity + */ + protected String getCreatePermission(Type clazz) { + return getPermission(clazz, CreatePermission.class); + } + + /** + * Get the calculated {@link ReadPermission} value for the entity. + * + * @param clazz the entity class + * @return the read permissions for an entity + */ + protected String getReadPermission(Type clazz) { + return getPermission(clazz, ReadPermission.class); + } + + /** + * Get the calculated {@link UpdatePermission} value for the entity. + * + * @param clazz the entity class + * @return the update permissions for an entity + */ + protected String getUpdatePermission(Type clazz) { + return getPermission(clazz, UpdatePermission.class); + } + + /** + * Get the calculated {@link DeletePermission} value for the entity. + * + * @param clazz the entity class + * @return the delete permissions for an entity + */ + protected String getDeletePermission(Type clazz) { + return getPermission(clazz, DeletePermission.class); + } + + /** + * Get the calculated {@link CreatePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the create permissions for the relationship + */ + protected String getCreatePermission(Type clazz, String field) { + return getPermission(clazz, field, CreatePermission.class); + } + + /** + * Get the calculated {@link ReadPermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the read permissions for the relationship + */ + protected String getReadPermission(Type clazz, String field) { + return getPermission(clazz, field, ReadPermission.class); + } + + /** + * Get the calculated {@link UpdatePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the update permissions for the relationship + */ + protected String getUpdatePermission(Type clazz, String field) { + return getPermission(clazz, field, UpdatePermission.class); + } + + /** + * Get the calculated {@link DeletePermission} value for the relationship. + * + * @param clazz the entity class + * @param field the field to inspect + * @return the delete permissions for the relationship + */ + protected String getDeletePermission(Type clazz, String field) { + return getPermission(clazz, field, DeletePermission.class); + } + + protected String getPermission(Type clazz, Class permission) { + ParseTree parseTree = dictionary.getPermissionsForClass(clazz, permission); + if (parseTree != null) { + return parseTree.getText(); + } + return null; + } + + protected String getPermission(Type clazz, String field, Class permission) { + ParseTree parseTree = dictionary.getPermissionsForField(clazz, field, permission); + if (parseTree != null) { + return parseTree.getText(); + } + return null; + } } diff --git a/elide-swagger/src/test/java/com/yahoo/elide/swagger/OpenApiBuilderTest.java b/elide-swagger/src/test/java/com/yahoo/elide/swagger/OpenApiBuilderTest.java index 6827024ecb..52c87de35b 100644 --- a/elide-swagger/src/test/java/com/yahoo/elide/swagger/OpenApiBuilderTest.java +++ b/elide-swagger/src/test/java/com/yahoo/elide/swagger/OpenApiBuilderTest.java @@ -12,7 +12,12 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.type.EntityFieldType; import com.yahoo.elide.core.type.EntityMethodType; @@ -28,6 +33,7 @@ import example.models.Author; import example.models.Book; +import example.models.Product; import example.models.Publisher; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -47,6 +53,9 @@ import io.swagger.v3.oas.models.tags.Tag; import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.Getter; import java.lang.annotation.Annotation; import java.util.Arrays; @@ -78,6 +87,7 @@ public void setup() { dictionary.bindEntity(Book.class); dictionary.bindEntity(Author.class); dictionary.bindEntity(Publisher.class); + dictionary.bindEntity(Product.class); Info info = new Info().title("Test Service").version(NO_VERSION); OpenApiBuilder builder = new OpenApiBuilder(dictionary).apiVersion(info.getVersion()); @@ -120,7 +130,10 @@ void testPathGeneration() throws Exception { assertTrue(openApi.getPaths().containsKey("/book/{bookId}/publisher/{publisherId}")); assertTrue(openApi.getPaths().containsKey("/book/{bookId}/relationships/publisher")); - assertEquals(16, openApi.getPaths().size()); + assertTrue(openApi.getPaths().containsKey("/product")); + assertTrue(openApi.getPaths().containsKey("/product/{productId}")); + + assertEquals(18, openApi.getPaths().size()); } @Test @@ -143,13 +156,22 @@ void testOperationGeneration() throws Exception { } assertNotNull(path.getPatch()); } else if (url.endsWith("Id}")) { //Instance URL - assertNotNull(path.getDelete()); + if (url.contains("product")) { + assertNull(path.getDelete()); // DeletePermission NONE + } else { + assertNotNull(path.getDelete()); + } assertNotNull(path.getPatch()); assertNull(path.getPost()); } else { //Collection URL assertNull(path.getDelete()); assertNull(path.getPatch()); - assertNotNull(path.getPost()); + + if (url.contains("product")) { + assertNull(path.getPost()); // CreatePermission NONE + } else { + assertNotNull(path.getPost()); + } } }); } @@ -298,13 +320,21 @@ void testOperationSuccessResponseCodes() throws Exception { assertTrue(patchOperation.getResponses().containsKey("204")); } else if (url.endsWith("Id}")) { //Instance URL Operation deleteOperation = path.getDelete(); - assertTrue(deleteOperation.getResponses().containsKey("204")); + if (url.contains("product")) { + assertNull(deleteOperation); // DeletePermission NONE + } else { + assertTrue(deleteOperation.getResponses().containsKey("204")); + } Operation patchOperation = path.getPatch(); assertTrue(patchOperation.getResponses().containsKey("204")); } else { //Collection URL Operation postOperation = path.getPost(); - assertTrue(postOperation.getResponses().containsKey("201")); + if (url.contains("product")) { + assertNull(postOperation); // CreatePermission NONE + } else { + assertTrue(postOperation.getResponses().containsKey("201")); + } } }); } @@ -474,7 +504,7 @@ void testSparseFieldsParam() throws Exception { void testTagGeneration() throws Exception { /* Check for the global tag definitions */ - assertEquals(3, openApi.getTags().size()); + assertEquals(4, openApi.getTags().size()); String bookTag = openApi.getTags().stream() .filter((tag) -> tag.getName().equals("book")) @@ -492,27 +522,27 @@ void testTagGeneration() throws Exception { /* For each operation, ensure its tagged with the root collection name */ openApi.getPaths().forEach((url, path) -> { if (url.endsWith("relationships/books")) { - path.getGet().getTags().contains(bookTag); - path.getPost().getTags().contains(bookTag); - path.getDelete().getTags().contains(bookTag); - path.getPatch().getTags().contains(bookTag); + assertTrue(path.getGet().getTags().contains(bookTag)); + assertTrue(path.getPost().getTags().contains(bookTag)); + assertTrue(path.getDelete().getTags().contains(bookTag)); + assertTrue(path.getPatch().getTags().contains(bookTag)); } else if (url.endsWith("/books")) { - path.getGet().getTags().contains(bookTag); - path.getPost().getTags().contains(bookTag); + assertTrue(path.getGet().getTags().contains(bookTag)); + assertTrue(path.getPost().getTags().contains(bookTag)); } else if (url.endsWith("{bookId}")) { - path.getGet().getTags().contains(bookTag); - path.getPatch().getTags().contains(bookTag); - path.getDelete().getTags().contains(bookTag); + assertTrue(path.getGet().getTags().contains(bookTag)); + assertTrue(path.getPatch().getTags().contains(bookTag)); + assertTrue(path.getDelete().getTags().contains(bookTag)); } else if (url.endsWith("relationships/publisher")) { - path.getGet().getTags().contains(publisherTag); - path.getPatch().getTags().contains(publisherTag); + assertTrue(path.getGet().getTags().contains(publisherTag.getName())); + assertTrue(path.getPatch().getTags().contains(publisherTag.getName())); } else if (url.endsWith("/publisher")) { - path.getGet().getTags().contains(publisherTag); - path.getPost().getTags().contains(publisherTag); + assertTrue(path.getGet().getTags().contains(publisherTag.getName())); + assertTrue(path.getPost().getTags().contains(publisherTag.getName())); } else if (url.endsWith("{publisherId}")) { - path.getGet().getTags().contains(publisherTag); - path.getPatch().getTags().contains(publisherTag); - path.getDelete().getTags().contains(publisherTag); + assertTrue(path.getGet().getTags().contains(publisherTag.getName())); + assertTrue(path.getPatch().getTags().contains(publisherTag.getName())); + assertTrue(path.getDelete().getTags().contains(publisherTag.getName())); } }); } @@ -710,6 +740,183 @@ void testRequiredRelationship() { assertTrue(relationships.getRequired().contains("authors")); } + @Test + void testEntityFilterCrud() { + EntityDictionary entityDictionary = EntityDictionary.builder().build(); + + entityDictionary.bindEntity(NoCreateEntity.class); + entityDictionary.bindEntity(NoReadEntity.class); + entityDictionary.bindEntity(NoUpdateEntity.class); + entityDictionary.bindEntity(NoDeleteEntity.class); + entityDictionary.bindEntity(NoReadIdEntity.class); + entityDictionary.bindEntity(NoUpdateIdEntity.class); + entityDictionary.bindEntity(NoDeleteIdEntity.class); + Info info = new Info().title("Test Service").version(NO_VERSION); + + String noCreateEntityTag = "noCreateEntity"; + String noReadEntityTag = "noReadEntity"; + String noUpdateEntityTag = "noUpdateEntity"; + String noDeleteEntityTag = "noDeleteEntity"; + String noReadIdEntityTag = "noReadIdEntity"; + String noUpdateIdEntityTag = "noUpdateIdEntity"; + String noDeleteIdEntityTag = "noDeleteIdEntity"; + + OpenApiBuilder builder = new OpenApiBuilder(entityDictionary).apiVersion(info.getVersion()); + OpenAPI testOpenApi = builder.build().info(info); + testOpenApi.getPaths().forEach((url, path) -> { + if (url.endsWith("noCreateEntity")) { + assertTrue(path.getGet().getTags().contains(noCreateEntityTag)); + assertNull(path.getPost()); // no permission + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noCreateEntity/{noCreateEntityId}")) { + assertTrue(path.getGet().getTags().contains(noCreateEntityTag)); + assertNull(path.getPost()); // id endpoint + assertTrue(path.getDelete().getTags().contains(noCreateEntityTag)); + assertTrue(path.getPatch().getTags().contains(noCreateEntityTag)); + } else if (url.endsWith("noReadEntity")) { + assertNull(path.getGet()); // no permission + assertTrue(path.getPost().getTags().contains(noReadEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noReadEntity/{noReadEntityId}")) { + assertNull(path.getGet()); // no permission + assertNull(path.getPost()); // id endpoint + assertTrue(path.getDelete().getTags().contains(noReadEntityTag)); + assertTrue(path.getPatch().getTags().contains(noReadEntityTag)); + } else if (url.endsWith("noUpdateEntity")) { + assertTrue(path.getGet().getTags().contains(noUpdateEntityTag)); + assertTrue(path.getPost().getTags().contains(noUpdateEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noUpdateEntity/{noUpdateEntityId}")) { + assertTrue(path.getGet().getTags().contains(noUpdateEntityTag)); + assertNull(path.getPost()); // id endpoint + assertTrue(path.getDelete().getTags().contains(noUpdateEntityTag)); + assertNull(path.getPatch()); + } else if (url.endsWith("noDeleteEntity")) { + assertTrue(path.getGet().getTags().contains(noDeleteEntityTag)); + assertTrue(path.getPost().getTags().contains(noDeleteEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noDeleteEntity/{noDeleteEntityId}")) { + assertTrue(path.getGet().getTags().contains(noDeleteEntityTag)); + assertNull(path.getPost()); // id endpoint + assertNull(path.getDelete()); // no permission; + assertTrue(path.getPatch().getTags().contains(noDeleteEntityTag)); + } else if (url.endsWith("/noReadIdEntity")) { + assertTrue(path.getGet().getTags().contains(noReadIdEntityTag)); + assertTrue(path.getPost().getTags().contains(noReadIdEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noReadIdEntity/{noReadIdEntityId}")) { + assertNull(path.getGet()); // no permission + assertNull(path.getPost()); // id endpoint + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("/noUpdateIdEntity")) { + assertTrue(path.getGet().getTags().contains(noUpdateIdEntityTag)); + assertTrue(path.getPost().getTags().contains(noUpdateIdEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noUpdateIdEntity/{noUpdateIdEntityId}")) { + assertNull(path.getGet()); + assertNull(path.getPost()); // id endpoint + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("/noDeleteIdEntity")) { + assertTrue(path.getGet().getTags().contains(noDeleteIdEntityTag)); + assertTrue(path.getPost().getTags().contains(noDeleteIdEntityTag)); + assertNull(path.getDelete()); // collection endpoint + assertNull(path.getPatch()); // collection endpoint + } else if (url.endsWith("noDeleteIdEntity/{noDeleteIdEntityId}")) { + assertNull(path.getGet()); + assertNull(path.getPost()); // id endpoint + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } + }); + } + + @Test + void testRelationshipFilterCrud() { + EntityDictionary entityDictionary = EntityDictionary.builder().build(); + + entityDictionary.bindEntity(RelatedEntity.class); + entityDictionary.bindEntity(RelationshipEntity.class); + entityDictionary.bindEntity(NoReadEntity.class); + entityDictionary.bindEntity(NoCreateEntity.class); + Info info = new Info().title("Test Service").version(NO_VERSION); + + String relatedEntityTag = "relatedEntity"; + String noReadEntityTag = "noReadEntity"; + String noCreateEntityTag = "noCreateEntity"; + + OpenApiBuilder builder = new OpenApiBuilder(entityDictionary).apiVersion(info.getVersion()); + OpenAPI testOpenApi = builder.build().info(info); + testOpenApi.getPaths().forEach((url, path) -> { + if (url.endsWith("relationshipEntity/{relationshipEntityId}/relationships/tomanynoupdate")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/relationships/toonenoupdate")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/relationships/tomanynoread")) { + assertNull(path.getGet()); + assertTrue(path.getPost().getTags().contains(relatedEntityTag)); + assertTrue(path.getDelete().getTags().contains(relatedEntityTag)); + assertTrue(path.getPatch().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/relationships/toonenoread")) { + assertNull(path.getGet()); + assertNull(path.getPost()); + assertNull(path.getDelete()); + assertTrue(path.getPatch().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/tomanynoupdate/{relatedEntityId}")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/toonenoupdate/{relatedEntityId}")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + assertNull(path.getDelete()); + assertNull(path.getPatch()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/tomanynoread/{relatedEntityId}")) { + assertNull(path.getGet()); + assertNull(path.getPost()); + assertTrue(path.getDelete().getTags().contains(relatedEntityTag)); + assertTrue(path.getPatch().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/toonenoread/{relatedEntityId}")) { + assertNull(path.getGet()); + assertNull(path.getPost()); + assertTrue(path.getDelete().getTags().contains(relatedEntityTag)); + assertTrue(path.getPatch().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/tomanynoupdate")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/toonenoupdate")) { + assertTrue(path.getGet().getTags().contains(relatedEntityTag)); + assertNull(path.getPost()); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/tomanynoread")) { + assertNull(path.getGet()); + assertTrue(path.getPost().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/toonenoread")) { + assertNull(path.getGet()); + assertTrue(path.getPost().getTags().contains(relatedEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/collectionenoread")) { + assertNull(path.getGet()); + assertTrue(path.getPost().getTags().contains(noReadEntityTag)); + } else if (url.endsWith("relationshipEntity/{relationshipEntityId}/collectionnocreate")) { + assertTrue(path.getGet().getTags().contains(noCreateEntityTag)); + assertNull(path.getPost()); + } + }); + } + /** * Verifies that the given property is of type 'Data' containing a reference to the given model. * @param content The content to check @@ -911,4 +1118,92 @@ public Optional> getUnderlyingClass() { return Optional.empty(); } } + + @Include + @CreatePermission(expression = "None") + public static class NoCreateEntity { + @Id + private Long id; + } + + @Include + @ReadPermission(expression = "None") + public static class NoReadEntity { + @Id + private Long id; + } + + @Include + @UpdatePermission(expression = "None") + public static class NoUpdateEntity { + @Id + private Long id; + } + + @Include + @DeletePermission(expression = "None") + public static class NoDeleteEntity { + @Id + private Long id; + } + + @Include + public static class NoReadIdEntity { + @Id + @Exclude + private Long id; + } + + @Include + public static class NoUpdateIdEntity { + @Id + @Exclude + private Long id; + } + + @Include + public static class NoDeleteIdEntity { + @Id + @Exclude + private Long id; + } + + @Include + @Getter + public static class RelatedEntity { + @Id + private Long id; + + private String name; + } + + @Include + @Getter + public static class RelationshipEntity { + @Id + private Long id; + + @OneToMany + @UpdatePermission(expression = "None") + private List tomanynoupdate; + + @ManyToOne + @UpdatePermission(expression = "None") + private RelatedEntity toonenoupdate; + + @OneToMany + @ReadPermission(expression = "None") + private List tomanynoread; + + @ManyToOne + @ReadPermission(expression = "None") + private RelatedEntity toonenoread; + + @OneToMany + private List collectionnoread; + + @OneToMany + private List collectionnocreate; + + } } diff --git a/elide-swagger/src/test/java/example/models/Book.java b/elide-swagger/src/test/java/example/models/Book.java index 3e91e4add6..78818f185d 100644 --- a/elide-swagger/src/test/java/example/models/Book.java +++ b/elide-swagger/src/test/java/example/models/Book.java @@ -6,7 +6,6 @@ package example.models; import com.yahoo.elide.annotation.CreatePermission; -import com.yahoo.elide.annotation.DeletePermission; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.UpdatePermission; @@ -27,7 +26,6 @@ @Include(friendlyName = "Book") @ReadPermission(expression = "Principal is author OR Principal is publisher") @CreatePermission(expression = "Principal is author") -@DeletePermission(expression = "Prefab.Role.None") @Schema(title = "Override Include Title", description = "A book") public class Book { @OneToMany diff --git a/elide-swagger/src/test/java/example/models/Product.java b/elide-swagger/src/test/java/example/models/Product.java new file mode 100644 index 0000000000..31a8611ceb --- /dev/null +++ b/elide-swagger/src/test/java/example/models/Product.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models; + +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.core.security.checks.prefab.Role; + +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +/** + * Product. + */ +@Entity +@Include(friendlyName = "Product") +@CreatePermission(expression = Role.NONE_ROLE) +@DeletePermission(expression = Role.NONE_ROLE) +@Getter +@Setter +public class Product { + String id; + String sku; + String packageName; +}