diff --git a/ConfigGenerators/CosmosCommands.txt b/ConfigGenerators/CosmosCommands.txt index 769933e650..8787f63411 100644 --- a/ConfigGenerators/CosmosCommands.txt +++ b/ConfigGenerators/CosmosCommands.txt @@ -1,6 +1,6 @@ init --config "dab-config.CosmosDb_NoSql.json" --database-type "cosmosdb_nosql" --connection-string "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" --cosmosdb_nosql-database "graphqldb" --cosmosdb_nosql-container "planet" --graphql-schema "schema.gql" --host-mode Development --cors-origin "http://localhost:5000" -add Planet --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --rest false --graphql "Planet:Planets" +add Planet --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.planet" --permissions "anonymous:create,read,update,delete" --graphql "Planet:Planets" update Planet --config "dab-config.CosmosDb_NoSql.json" --permissions "authenticated:create,read,update,delete" -add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.character" --permissions "authenticated:create,read,update,delete" --rest false --graphql "Character:Characters" -add StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.star" --permissions "anonymous:create,read,update,delete" --rest false --graphql "Star:Stars" +add Character --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.character" --permissions "authenticated:create,read,update,delete" --graphql "Character:Characters" +add StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.star" --permissions "anonymous:create,read,update,delete" --graphql "Star:Stars" update StarAlias --config "dab-config.CosmosDb_NoSql.json" --source "graphqldb.star" --permissions "authenticated:create,read,update,delete" diff --git a/ConfigGenerators/MsSqlCommands.txt b/ConfigGenerators/MsSqlCommands.txt index fc653d4814..2faf7f749a 100644 --- a/ConfigGenerators/MsSqlCommands.txt +++ b/ConfigGenerators/MsSqlCommands.txt @@ -25,15 +25,15 @@ add Journal --config "dab-config.MsSql.json" --source "journals" --rest true --g add ArtOfWar --config "dab-config.MsSql.json" --source "aow" --rest true --graphql false --permissions "anonymous:*" add series --config "dab-config.MsSql.json" --source "series" --permissions "anonymous:*" add Sales --config "dab-config.MsSql.json" --source "sales" --permissions "anonymous:*" --rest true --graphql true -add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql true -add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql false -add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --source.params "id:1" --permissions "anonymous:read" --rest true --graphql true -add InsertBook --config "dab-config.MsSql.json" --source "insert_book" --source.type "stored-procedure" --source.params "title:randomX,publisher_id:1234" --permissions "anonymous:create" --rest true --graphql true -add CountBooks --config "dab-config.MsSql.json" --source "count_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql true -add DeleteLastInsertedBook --config "dab-config.MsSql.json" --source "delete_last_inserted_book" --source.type "stored-procedure" --permissions "anonymous:delete" --rest true --graphql true -add UpdateBookTitle --config "dab-config.MsSql.json" --source "update_book_title" --source.type "stored-procedure" --source.params "id:1,title:Testing Tonight" --permissions "anonymous:update" --rest true --graphql true -add GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --source "get_authors_history_by_first_name" --source.type "stored-procedure" --source.params "firstName:Aaron" --permissions "anonymous:read" --rest true --graphql SearchAuthorByFirstName -add InsertAndDisplayAllBooksUnderGivenPublisher --config "dab-config.MsSql.json" --source "insert_and_display_all_books_for_given_publisher" --source.type "stored-procedure" --source.params "title:MyTitle,publisher_name:MyPublisher" --permissions "anonymous:create" --rest true --graphql true +add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true +add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql false +add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --source.params "id:1" --permissions "anonymous:execute" --rest true --graphql true +add InsertBook --config "dab-config.MsSql.json" --source "insert_book" --source.type "stored-procedure" --source.params "title:randomX,publisher_id:1234" --permissions "anonymous:execute" --rest true --graphql true +add CountBooks --config "dab-config.MsSql.json" --source "count_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true +add DeleteLastInsertedBook --config "dab-config.MsSql.json" --source "delete_last_inserted_book" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true +add UpdateBookTitle --config "dab-config.MsSql.json" --source "update_book_title" --source.type "stored-procedure" --source.params "id:1,title:Testing Tonight" --permissions "anonymous:execute" --rest true --graphql true +add GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --source "get_authors_history_by_first_name" --source.type "stored-procedure" --source.params "firstName:Aaron" --permissions "anonymous:execute" --rest true --graphql SearchAuthorByFirstName +add InsertAndDisplayAllBooksUnderGivenPublisher --config "dab-config.MsSql.json" --source "insert_and_display_all_books_for_given_publisher" --source.type "stored-procedure" --source.params "title:MyTitle,publisher_name:MyPublisher" --permissions "anonymous:execute" --rest true --graphql true add GQLmappings --config "dab-config.MsSql.json" --source "GQLmappings" --permissions "anonymous:*" --rest true --graphql true update GQLmappings --config "dab-config.MsSql.json" --map "__column1:column1,__column2:column2" --permissions "authenticated:*" update Publisher --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship books --target.entity Book --cardinality many @@ -116,13 +116,13 @@ update Journal --config "dab-config.MsSql.json" --permissions "policy_tester_upd update Journal --config "dab-config.MsSql.json" --permissions "policy_tester_update_noread:delete" --fields.include "*" --policy-database "@item.id eq 1" update Journal --config "dab-config.MsSql.json" --permissions "authorizationHandlerTester:read" update ArtOfWar --config "dab-config.MsSql.json" --permissions "authenticated:*" --map "DetailAssessmentAndPlanning:始計,WagingWar:作戰,StrategicAttack:謀攻,NoteNum:┬─┬ノ( º _ ºノ)" -update GetBook --config "dab-config.MsSql.json" --permissions "authenticated:read" -update GetPublisher --config "dab-config.MsSql.json" --permissions "authenticated:read" -update GetBooks --config "dab-config.MsSql.json" --permissions "authenticated:read" -update InsertBook --config "dab-config.MsSql.json" --permissions "authenticated:create" -update CountBooks --config "dab-config.MsSql.json" --permissions "authenticated:read" -update DeleteLastInsertedBook --config "dab-config.MsSql.json" --permissions "authenticated:delete" -update UpdateBookTitle --config "dab-config.MsSql.json" --permissions "authenticated:update" +update GetBook --config "dab-config.MsSql.json" --permissions "authenticated:execute" --rest.methods "Get" +update GetPublisher --config "dab-config.MsSql.json" --permissions "authenticated:execute" +update GetBooks --config "dab-config.MsSql.json" --permissions "authenticated:execute" --graphql.operation "Query" --rest.methods "Get" +update InsertBook --config "dab-config.MsSql.json" --permissions "authenticated:execute" +update CountBooks --config "dab-config.MsSql.json" --permissions "authenticated:execute" +update DeleteLastInsertedBook --config "dab-config.MsSql.json" --permissions "authenticated:execute" +update UpdateBookTitle --config "dab-config.MsSql.json" --permissions "authenticated:execute" update Sales --config "dab-config.MsSql.json" --permissions "authenticated:*" -update GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --permissions "authenticated:read" -update InsertAndDisplayAllBooksUnderGivenPublisher --config "dab-config.MsSql.json" --permissions "authenticated:create" +update GetAuthorsHistoryByFirstName --config "dab-config.MsSql.json" --permissions "authenticated:execute" +update InsertAndDisplayAllBooksUnderGivenPublisher --config "dab-config.MsSql.json" --permissions "authenticated:execute" diff --git a/ConfigGenerators/dab-config.mssql.reference.json b/ConfigGenerators/dab-config.mssql.reference.json index dba3754a6c..e444954def 100644 --- a/ConfigGenerators/dab-config.mssql.reference.json +++ b/ConfigGenerators/dab-config.mssql.reference.json @@ -915,15 +915,18 @@ "object": "get_books" }, "rest": true, - "graphql": true, + "graphql": { + "type": "GetBooks", + "operation": "query" + }, "permissions": [ { "role": "anonymous", - "actions": [ "read" ] + "actions": [ "execute" ] }, { "role": "authenticated", - "actions": [ "read" ] + "actions": [ "execute" ] } ] }, @@ -932,16 +935,19 @@ "type": "stored-procedure", "object": "get_book_by_id" }, - "rest": true, + "rest": { + "path": "GetBook", + "methods": [ "GET" ] + }, "graphql": false, "permissions": [ { "role": "anonymous", - "actions": [ "read" ] + "actions": [ "execute" ] }, { "role": "authenticated", - "actions": [ "read" ] + "actions": [ "execute" ] } ] }, @@ -978,22 +984,28 @@ }, "key-fields": [] }, - "rest": true, + "rest": { + "path": "InsertBook", + "methods": [ "POST" ] + }, "permissions": [ { "role": "anonymous", "actions": [ - "create" + "execute" ] }, { "role": "authenticated", "actions": [ - "create" + "execute" ] } ], - "graphql": true + "graphql": { + "type": "InsertBook", + "operation": "mutation" + } }, "CountBooks": { "source": { @@ -1001,22 +1013,28 @@ "object": "count_books", "key-fields": [] }, - "rest": true, + "rest": { + "path": "CountBooks", + "methods": [ "GET" ] + }, "permissions": [ { "role": "anonymous", "actions": [ - "read" + "execute" ] }, { "role": "authenticated", "actions": [ - "read" + "execute" ] } ], - "graphql": true + "graphql": { + "type": "CountBooks", + "operation": "query" + } }, "DeleteLastInsertedBook": { "source": { @@ -1024,7 +1042,10 @@ "object": "delete_last_inserted_book", "key-fields": [] }, - "rest": true, + "rest": { + "path": "DeleteLastInsertedBook", + "methods": [ "DELETE" ] + }, "permissions": [ { "role": "anonymous", @@ -1039,7 +1060,10 @@ ] } ], - "graphql": true + "graphql": { + "type": "DeleteLastInsertedBook", + "operation": "mutation" + } }, "UpdateBookTitle": { "source": { @@ -1051,22 +1075,28 @@ }, "key-fields": [] }, - "rest": true, + "rest": { + "path": "UpdateBookTitle", + "methods": [ "PUT", "PATCH" ] + }, "permissions": [ { "role": "anonymous", "actions": [ - "update" + "execute" ] }, { "role": "authenticated", "actions": [ - "update" + "execute" ] } ], - "graphql": true + "graphql": { + "type": "UpdateBookTitle", + "operation": "mutation" + } }, "GetPublisher": { "source": { @@ -1076,7 +1106,10 @@ "id": 1 } }, - "rest": true, + "rest": { + "path": "GetPublisher", + "methods": [ "GET" ] + }, "permissions": [ { "role": "anonymous", @@ -1091,7 +1124,10 @@ ] } ], - "graphql": true + "graphql": { + "type": "GetPublisher", + "operation": "query" + } }, "GQLmappings": { "source": { diff --git a/ConfigGenerators/dab-config.sql.reference.json b/ConfigGenerators/dab-config.sql.reference.json index c30b52a800..a355b2f529 100644 --- a/ConfigGenerators/dab-config.sql.reference.json +++ b/ConfigGenerators/dab-config.sql.reference.json @@ -906,42 +906,6 @@ } } }, - "GetBooks": { - "source": { - "type": "stored-procedure", - "object": "get_books" - }, - "rest": true, - "graphql": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ "read" ] - }, - { - "role": "authenticated", - "actions": [ "read" ] - } - ] - }, - "GetBook": { - "source": { - "type": "stored-procedure", - "object": "get_book_by_id" - }, - "rest": true, - "graphql": false, - "permissions": [ - { - "role": "anonymous", - "actions": [ "read" ] - }, - { - "role": "authenticated", - "actions": [ "read" ] - } - ] - }, "Sales": { "source": { "type": "table", @@ -965,131 +929,6 @@ } ] }, - "InsertBook": { - "source": { - "type": "stored-procedure", - "object": "insert_book", - "parameters": { - "title": "randomX", - "publisher_id": 1234 - }, - "key-fields": [] - }, - "rest": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ - "create" - ] - }, - { - "role": "authenticated", - "actions": [ - "create" - ] - } - ], - "graphql": true - }, - "CountBooks": { - "source": { - "type": "stored-procedure", - "object": "count_books", - "key-fields": [] - }, - "rest": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ - "read" - ] - }, - { - "role": "authenticated", - "actions": [ - "read" - ] - } - ], - "graphql": true - }, - "DeleteLastInsertedBook": { - "source": { - "type": "stored-procedure", - "object": "delete_last_inserted_book", - "key-fields": [] - }, - "rest": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ - "delete" - ] - }, - { - "role": "authenticated", - "actions": [ - "delete" - ] - } - ], - "graphql": true - }, - "UpdateBookTitle": { - "source": { - "type": "stored-procedure", - "object": "update_book_title", - "parameters": { - "id": 1, - "title": "Testing Tonight" - }, - "key-fields": [] - }, - "rest": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ - "update" - ] - }, - { - "role": "authenticated", - "actions": [ - "update" - ] - } - ], - "graphql": true - }, - "GetPublisher": { - "source": { - "type": "stored-procedure", - "object": "get_publisher_by_id", - "parameters": { - "id": 1 - } - }, - "rest": true, - "permissions": [ - { - "role": "anonymous", - "actions": [ - "read" - ] - }, - { - "role": "authenticated", - "actions": [ - "read" - ] - } - ], - "graphql": true - }, "GQLmappings": { "source": "GQLmappings", "graphql": true, diff --git a/src/Auth/AuthorizationMetadataHelpers.cs b/src/Auth/AuthorizationMetadataHelpers.cs index 6fc2464c45..87273e259b 100644 --- a/src/Auth/AuthorizationMetadataHelpers.cs +++ b/src/Auth/AuthorizationMetadataHelpers.cs @@ -34,6 +34,17 @@ public class EntityMetadata /// i.e. Read operation is permitted in {Role1, Role2, ..., RoleN} /// public Dictionary> OperationToRolesMap { get; set; } = new(); + + /// + /// Set of Http verbs enabled for Stored Procedure entities that have their REST endpoint enabled. + /// + public HashSet StoredProcedureHttpVerbs { get; set; } = new(); + + /// + /// Defines the type of database object the entity represents. + /// Examples include Table, View, StoredProcedure + /// + public SourceType ObjectType { get; set; } = SourceType.Table; } /// diff --git a/src/Auth/IAuthorizationResolver.cs b/src/Auth/IAuthorizationResolver.cs index f0d1b4ea69..8a01332ec3 100644 --- a/src/Auth/IAuthorizationResolver.cs +++ b/src/Auth/IAuthorizationResolver.cs @@ -83,6 +83,16 @@ public interface IAuthorizationResolver /// Collection of role names allowed to perform operation on Entity's field. public IEnumerable GetRolesForField(string entityName, string field, Operation operation); + /// + /// Returns whether the httpVerb (GET, POST, PUT, PATCH, DELETE) is allowed to be performed + /// on the stored procedure (represented by entityName) for the role: roleName. + /// + /// + /// + /// + /// True if the execution of the stored procedure is permitted. Otherwise, false. + public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, RestMethod httpVerb); + /// /// Returns a list of roles which define permissions for the provided operation. /// i.e. list of roles which allow the operation 'Read' on entityName. diff --git a/src/Cli/src/CommandLineOptions.cs b/src/Cli/src/CommandLineOptions.cs index ee037f40bf..f29ca8d9aa 100644 --- a/src/Cli/src/CommandLineOptions.cs +++ b/src/Cli/src/CommandLineOptions.cs @@ -99,7 +99,9 @@ public EntityOptions( IEnumerable? sourceParameters, IEnumerable? sourceKeyFields, string? restRoute, + IEnumerable? restMethodsForStoredProcedure, string? graphQLType, + string? graphQLOperationForStoredProcedure, IEnumerable? fieldsToInclude, IEnumerable? fieldsToExclude, string? policyRequest, @@ -112,7 +114,9 @@ public EntityOptions( SourceParameters = sourceParameters; SourceKeyFields = sourceKeyFields; RestRoute = restRoute; + RestMethodsForStoredProcedure = restMethodsForStoredProcedure; GraphQLType = graphQLType; + GraphQLOperationForStoredProcedure = graphQLOperationForStoredProcedure; FieldsToInclude = fieldsToInclude; FieldsToExclude = fieldsToExclude; PolicyRequest = policyRequest; @@ -135,9 +139,15 @@ public EntityOptions( [Option("rest", Required = false, HelpText = "Route for rest api.")] public string? RestRoute { get; } + [Option("rest.methods", Required = false, Separator = ',', HelpText = "HTTP actions to be supported for stored procedure. Specify the actions as a comma separated list. Valid HTTP actions are : [GET, POST, PUT, PATCH, DELETE]")] + public IEnumerable? RestMethodsForStoredProcedure { get; } + [Option("graphql", Required = false, HelpText = "Type of graphQL.")] public string? GraphQLType { get; } + [Option("graphql.operation", Required = false, HelpText = $"GraphQL operation to be supported for stored procedure. Valid operations are : [Query, Mutation] ")] + public string? GraphQLOperationForStoredProcedure { get; } + [Option("fields.include", Required = false, Separator = ',', HelpText = "Fields that are allowed access to permission.")] public IEnumerable? FieldsToInclude { get; } @@ -165,7 +175,9 @@ public AddOptions( IEnumerable? sourceParameters, IEnumerable? sourceKeyFields, string? restRoute, + IEnumerable? restMethodsForStoredProcedure, string? graphQLType, + string? graphQLOperationForStoredProcedure, IEnumerable? fieldsToInclude, IEnumerable? fieldsToExclude, string? policyRequest, @@ -176,7 +188,9 @@ public AddOptions( sourceParameters, sourceKeyFields, restRoute, + restMethodsForStoredProcedure, graphQLType, + graphQLOperationForStoredProcedure, fieldsToInclude, fieldsToExclude, policyRequest, @@ -216,7 +230,9 @@ public UpdateOptions( IEnumerable? sourceParameters, IEnumerable? sourceKeyFields, string? restRoute, + IEnumerable? restMethodsForStoredProcedure, string? graphQLType, + string? graphQLOperationForStoredProcedure, IEnumerable? fieldsToInclude, IEnumerable? fieldsToExclude, string? policyRequest, @@ -227,7 +243,9 @@ public UpdateOptions( sourceParameters, sourceKeyFields, restRoute, + restMethodsForStoredProcedure, graphQLType, + graphQLOperationForStoredProcedure, fieldsToInclude, fieldsToExclude, policyRequest, diff --git a/src/Cli/src/ConfigGenerator.cs b/src/Cli/src/ConfigGenerator.cs index 7d6769f8b1..0ebe491933 100644 --- a/src/Cli/src/ConfigGenerator.cs +++ b/src/Cli/src/ConfigGenerator.cs @@ -201,12 +201,68 @@ public static bool TryAddNewEntity(AddOptions options, ref string runtimeConfigJ return false; } + bool isStoredProcedure = IsStoredProcedure(options); + // Validations to ensure that REST methods and GraphQL operations can be configured only + // for stored procedures + if (options.GraphQLOperationForStoredProcedure is not null && !isStoredProcedure) + { + _logger.LogError("--graphql.operation can be configured only for stored procedures."); + return false; + } + + if ((options.RestMethodsForStoredProcedure is not null && options.RestMethodsForStoredProcedure.Any()) + && !isStoredProcedure) + { + _logger.LogError("--rest.methods can be configured only for stored procedures."); + return false; + } + + GraphQLOperation? graphQLOperationsForStoredProcedures = null; + RestMethod[]? restMethods = null; + if (isStoredProcedure) + { + if (CheckConflictingGraphQLConfigurationForStoredProcedures(options)) + { + _logger.LogError("Conflicting GraphQL configurations found."); + return false; + } + + if (!TryAddGraphQLOperationForStoredProcedure(options, out graphQLOperationsForStoredProcedures)) + { + return false; + } + + if (CheckConflictingRestConfigurationForStoredProcedures(options)) + { + _logger.LogError("Conflicting Rest configurations found."); + return false; + } + + if (!TryAddRestMethodsForStoredProcedure(options, out restMethods)) + { + return false; + } + } + + object? restPathDetails = ConstructRestPathDetails(options.RestRoute); + object? graphQLNamingConfig = ConstructGraphQLTypeDetails(options.GraphQLType); + + if (restPathDetails is not null && restPathDetails is false) + { + restMethods = null; + } + + if (graphQLNamingConfig is not null && graphQLNamingConfig is false) + { + graphQLOperationsForStoredProcedures = null; + } + // Create new entity. // Entity entity = new( source!, - GetRestDetails(options.RestRoute), - GetGraphQLDetails(options.GraphQLType), + GetRestDetails(restPathDetails, restMethods), + GetGraphQLDetails(graphQLNamingConfig, graphQLOperationsForStoredProcedures), permissionSettings, Relationships: null, Mappings: null); @@ -307,7 +363,6 @@ public static bool TryCreateSourceObjectForNewEntity( // Parse the SourceType. // Parsing won't fail as this check is already done during source object creation. SourceTypeEnumConverter.TryGetSourceType(sourceType, out SourceType sourceObjectType); - // Check if provided operations are valid if (!VerifyOperations(operations!.Split(","), sourceObjectType)) { @@ -387,8 +442,42 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, ref string run return false; } - object? updatedRestDetails = options.RestRoute is null ? entity!.Rest : GetRestDetails(options.RestRoute); - object? updatedGraphQLDetails = options.GraphQLType is null ? entity!.GraphQL : GetGraphQLDetails(options.GraphQLType); + bool isCurrentEntityStoredProcedure = IsStoredProcedure(entity); + bool doOptionsRepresentStoredProcedure = options.SourceType is not null && IsStoredProcedure(options); + + // Validations to ensure that REST methods and GraphQL operations can be configured only + // for stored procedures + if (options.GraphQLOperationForStoredProcedure is not null && + !(isCurrentEntityStoredProcedure || doOptionsRepresentStoredProcedure)) + { + _logger.LogError("--graphql.operation can be configured only for stored procedures."); + return false; + } + + if ((options.RestMethodsForStoredProcedure is not null && options.RestMethodsForStoredProcedure.Any()) + && !(isCurrentEntityStoredProcedure || doOptionsRepresentStoredProcedure)) + { + _logger.LogError("--rest.methods can be configured only for stored procedures."); + return false; + } + + if (isCurrentEntityStoredProcedure || doOptionsRepresentStoredProcedure) + { + if (CheckConflictingGraphQLConfigurationForStoredProcedures(options)) + { + _logger.LogError("Conflicting GraphQL configurations found."); + return false; + } + + if (CheckConflictingRestConfigurationForStoredProcedures(options)) + { + _logger.LogError("Conflicting Rest configurations found."); + return false; + } + } + + object? updatedRestDetails = ConstructUpdatedRestDetails(entity, options); + object? updatedGraphQLDetails = ConstructUpdatedGraphQLDetails(entity, options); PermissionSetting[]? updatedPermissions = entity!.Permissions; Dictionary? updatedRelationships = entity.Relationships; Dictionary? updatedMappings = entity.Mappings; @@ -534,7 +623,7 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, ref string run else { // User didn't use WILDCARD, and wants to update some of the operations. - IDictionary existingOperations = ConvertOperationArrayToIEnumerable(permission.Operations); + IDictionary existingOperations = ConvertOperationArrayToIEnumerable(permission.Operations, entityToUpdate.ObjectType); // Merge existing operations with new operations object[] updatedOperationArray = GetUpdatedOperationArray(newOperationArray, policy, fields, existingOperations); @@ -656,6 +745,12 @@ private static bool TryGetUpdatedSourceObjectWithOptions( ); return false; } + + if (IsStoredProcedureConvertedToOtherTypes(entity, options) || IsEntityBeingConvertedToStoredProcedure(entity, options)) + { + _logger.LogWarning($"Stored procedures can be configured only with {Operation.Execute.ToString()} action whereas tables/views are configured with CRUD actions. Update the actions configured for all the roles for this entity."); + } + } if (!VerifyCorrectPairingOfParameterAndKeyFieldsWithType( @@ -687,7 +782,7 @@ private static bool TryGetUpdatedSourceObjectWithOptions( return false; } - if (options.SourceKeyFields is not null) + if (options.SourceKeyFields is not null && options.SourceKeyFields.Any()) { updatedKeyFields = options.SourceKeyFields.ToArray(); } @@ -844,5 +939,180 @@ public static bool TryStartEngineWithOptions(StartOptions options) return Azure.DataApiBuilder.Service.Program.StartEngine(args.ToArray()); } + + /// + /// Returns an array of RestMethods resolved from command line input (EntityOptions). + /// When no methods are specified, the default "POST" is returned. + /// + /// Entity configuration options received from command line input. + /// Rest methods to enable for stored procedure. + /// True when the default (POST) or user provided stored procedure REST methods are supplied. + /// Returns false and an empty array when an invalid REST method is provided. + private static bool TryAddRestMethodsForStoredProcedure(EntityOptions options, [NotNullWhen(true)] out RestMethod[]? restMethods) + { + if (options.RestMethodsForStoredProcedure is null || !options.RestMethodsForStoredProcedure.Any()) + { + restMethods = new RestMethod[] { RestMethod.Post }; + } + else + { + restMethods = CreateRestMethods(options.RestMethodsForStoredProcedure); + } + + return restMethods.Length > 0; + } + + /// + /// Identifies the graphQL operations configured for the stored procedure from add command. + /// When no value is specified, the stored procedure is configured with a mutation operation. + /// Returns true/false corresponding to a successful/unsuccessful conversion of the operations. + /// + /// GraphQL operations configured for the Stored Procedure using add command + /// GraphQL Operations as Enum type + /// True when a user declared GraphQL operation on a stored procedure backed entity is supported. False, otherwise. + private static bool TryAddGraphQLOperationForStoredProcedure(EntityOptions options, [NotNullWhen(true)] out GraphQLOperation? graphQLOperationForStoredProcedure) + { + if (options.GraphQLOperationForStoredProcedure is null) + { + graphQLOperationForStoredProcedure = GraphQLOperation.Mutation; + } + else + { + if (!TryConvertGraphQLOperationNameToGraphQLOperation(options.GraphQLOperationForStoredProcedure, out GraphQLOperation operation)) + { + graphQLOperationForStoredProcedure = null; + return false; + } + + graphQLOperationForStoredProcedure = operation; + } + + return true; + } + + /// + /// Constructs the updated REST settings based on the input from update command and + /// existing REST configuration for an entity + /// + /// Entity for which the REST settings are updated + /// Input from update command + /// Boolean -> when the entity's REST configuration is true/false. + /// RestEntitySettings -> when a non stored procedure entity is configured with granular REST settings (Path). + /// RestStoredProcedureEntitySettings -> when a stored procedure entity is configured with explicit RestMethods. + /// RestStoredProcedureEntityVerboseSettings-> when a stored procedure entity is configured with explicit RestMethods and Path settings. + private static object? ConstructUpdatedRestDetails(Entity entity, EntityOptions options) + { + + // Updated REST Route details + object? restPath = (options.RestRoute is not null) ? ConstructRestPathDetails(options.RestRoute) : entity.GetRestEnabledOrPathSettings(); + + // Updated REST Methods info for stored procedures + RestMethod[]? restMethods; + if (!IsStoredProcedureConvertedToOtherTypes(entity, options) + && (IsStoredProcedure(entity) || IsStoredProcedure(options))) + { + if (options.RestMethodsForStoredProcedure is null || !options.RestMethodsForStoredProcedure.Any()) + { + restMethods = entity.GetRestMethodsConfiguredForStoredProcedure(); + } + else + { + restMethods = CreateRestMethods(options.RestMethodsForStoredProcedure); + } + } + else + { + restMethods = null; + } + + if (restPath is false) + { + // Non-stored procedure scenario when the REST endpoint is disabled for the entity. + if (options.RestRoute is not null) + { + restMethods = null; + } + else + { + if (options.RestMethodsForStoredProcedure is not null && options.RestMethodsForStoredProcedure.Any()) + { + restPath = null; + } + } + } + + if (IsEntityBeingConvertedToStoredProcedure(entity, options) + && (restMethods is null || restMethods.Length == 0)) + { + restMethods = new RestMethod[] { RestMethod.Post }; + } + + return GetRestDetails(restPath, restMethods); + } + + /// + /// Constructs the updated GraphQL settings based on the input from update command and + /// existing graphQL configuration for an entity + /// + /// Entity for which GraphQL settings are updated + /// Input from update command + /// Boolean -> when the entity's GraphQL configuration is true/false. + /// GraphQLEntitySettings -> when a non stored procedure entity is configured with granular GraphQL settings (Type/Singular/Plural). + /// GraphQLStoredProcedureEntitySettings -> when a stored procedure entity is configured with an explicit operation. + /// GraphQLStoredProcedureEntityVerboseSettings-> when a stored procedure entity is configured with explicit operation and type settings. + private static object? ConstructUpdatedGraphQLDetails(Entity entity, EntityOptions options) + { + //Updated GraphQL Type + object? graphQLType = (options.GraphQLType is not null) ? ConstructGraphQLTypeDetails(options.GraphQLType) : entity.GetGraphQLEnabledOrPath(); + GraphQLOperation? graphQLOperation; + + if (!IsStoredProcedureConvertedToOtherTypes(entity, options) + && (IsStoredProcedure(entity) || IsStoredProcedure(options))) + { + if (options.GraphQLOperationForStoredProcedure is null) + { + graphQLOperation = entity.FetchGraphQLOperation(); + } + else + { + GraphQLOperation operation; + if (TryConvertGraphQLOperationNameToGraphQLOperation(options.GraphQLOperationForStoredProcedure, out operation)) + { + graphQLOperation = operation; + } + else + { + graphQLOperation = null; + } + } + } + else + { + graphQLOperation = null; + } + + if (graphQLType is false) + { + if (options.GraphQLType is not null) + { + graphQLOperation = null; + } + else + { + if (options.GraphQLOperationForStoredProcedure is not null) + { + graphQLType = null; + } + } + } + + if (IsEntityBeingConvertedToStoredProcedure(entity, options) + && graphQLOperation is null) + { + graphQLOperation = GraphQLOperation.Mutation; + } + + return GetGraphQLDetails(graphQLType, graphQLOperation); + } } } diff --git a/src/Cli/src/Utils.cs b/src/Cli/src/Utils.cs index 88b15b61ce..a0a5f9ccbc 100644 --- a/src/Cli/src/Utils.cs +++ b/src/Cli/src/Utils.cs @@ -45,76 +45,74 @@ public static string GetProductVersion() } /// - /// Creates the rest object which can be either a boolean value - /// or a RestEntitySettings object containing api route based on the input + /// Creates the REST object which can be either a boolean value + /// or a RestEntitySettings object containing api route based on the input. + /// Returns null when no REST configuration is provided. /// - public static object? GetRestDetails(string? rest) + public static object? GetRestDetails(object? restDetail = null, RestMethod[]? restMethods = null) { - object? rest_detail; - if (rest is null) + if (restDetail is null && restMethods is null) { - return rest; + return null; } - - bool trueOrFalse; - if (bool.TryParse(rest, out trueOrFalse)) + // Tables, Views and Stored Procedures that are enabled for REST without custom + // path or methods. + else if (restDetail is not null && restMethods is null) { - rest_detail = trueOrFalse; + if (restDetail is true || restDetail is false) + { + return restDetail; + } + else + { + return new RestEntitySettings(Path: restDetail); + } } - else + //Stored Procedures that have REST methods defined without a custom REST path definition + else if (restMethods is not null && restDetail is null) { - RestEntitySettings restEntitySettings = new("/" + rest); - rest_detail = restEntitySettings; + return new RestStoredProcedureEntitySettings(RestMethods: restMethods); } - return rest_detail; + //Stored Procedures that have custom REST path and methods defined + return new RestStoredProcedureEntityVerboseSettings(Path: restDetail, RestMethods: restMethods!); } /// /// Creates the graphql object which can be either a boolean value /// or a GraphQLEntitySettings object containing graphql type {singular, plural} based on the input /// - public static object? GetGraphQLDetails(string? graphQL) + public static object? GetGraphQLDetails(object? graphQLDetail, GraphQLOperation? graphQLOperation = null) { - object? graphQL_detail; - if (graphQL is null) - { - return graphQL; - } - bool trueOrFalse; - if (bool.TryParse(graphQL, out trueOrFalse)) + if (graphQLDetail is null && graphQLOperation is null) { - graphQL_detail = trueOrFalse; + return null; } - else + // Tables, view or stored procedures that are either enabled for graphQL without custom operation + // definitions and with/without a custom graphQL type definition. + else if (graphQLDetail is not null && graphQLOperation is null) { - string singular, plural; - if (graphQL.Contains(SEPARATOR)) + if (graphQLDetail is true || graphQLDetail is false) { - string[] arr = graphQL.Split(SEPARATOR); - if (arr.Length != 2) - { - _logger.LogError($"Invalid format for --graphql. Accepted values are true/false," + - "a string, or a pair of string in the format :"); - return null; - } - - singular = arr[0]; - plural = arr[1]; + return graphQLDetail; } else { - singular = graphQL.Singularize(inputIsKnownToBePlural: false); - plural = graphQL.Pluralize(inputIsKnownToBeSingular: false); + return new GraphQLEntitySettings(Type: graphQLDetail); } - - SingularPlural singularPlural = new(singular, plural); - GraphQLEntitySettings graphQLEntitySettings = new(singularPlural); - graphQL_detail = graphQLEntitySettings; + } + // Stored procedures that are defined with custom graphQL operations but without + // custom type definitions. + else if (graphQLDetail is null && graphQLOperation is not null) + { + return new GraphQLStoredProcedureEntityOperationSettings(GraphQLOperation: graphQLOperation.ToString()); } - return graphQL_detail; + // Stored procedures that are defined with custom graphQL type definition and + // custom a graphQL operation. + return new GraphQLStoredProcedureEntityVerboseSettings(Type: graphQLDetail, GraphQLOperation: graphQLOperation.ToString()); + } /// @@ -190,7 +188,7 @@ public static object[] CreateOperations(string operations, Policy? policy, Field /// /// Array of operations which is of type JsonElement. /// Dictionary of operations - public static IDictionary ConvertOperationArrayToIEnumerable(object[] operations) + public static IDictionary ConvertOperationArrayToIEnumerable(object[] operations, SourceType sourceType) { Dictionary result = new(); foreach (object operation in operations) @@ -202,8 +200,9 @@ public static IDictionary ConvertOperationArrayT { if (op is Operation.All) { - // Expand wildcard to all valid operations - foreach (Operation validOp in PermissionOperation.ValidPermissionOperations) + HashSet resolvedOperations = sourceType is SourceType.StoredProcedure ? PermissionOperation.ValidStoredProcedurePermissionOperations : PermissionOperation.ValidPermissionOperations; + // Expand wildcard to all valid operations (except execute) + foreach (Operation validOp in resolvedOperations) { result.Add(validOp, new PermissionOperation(validOp, null, null)); } @@ -220,12 +219,11 @@ public static IDictionary ConvertOperationArrayT if (ac.Name is Operation.All) { - // Expand wildcard to all valid operations. - foreach (Operation validOp in PermissionOperation.ValidPermissionOperations) + // Expand wildcard to all valid operations except execute. + HashSet resolvedOperations = sourceType is SourceType.StoredProcedure ? PermissionOperation.ValidStoredProcedurePermissionOperations : PermissionOperation.ValidPermissionOperations; + foreach (Operation validOp in resolvedOperations) { - result.Add( - validOp, - new PermissionOperation(validOp, Policy: ac.Policy, Fields: ac.Fields)); + result.Add(validOp, new PermissionOperation(validOp, Policy: ac.Policy, Fields: ac.Fields)); } } else @@ -448,9 +446,9 @@ public static bool VerifyOperations(string[] operations, SourceType sourceType) return false; } - // Currently, Stored Procedures can be configured with only 1 CRUD Operation. - if (sourceType is SourceType.StoredProcedure - && !VerifySingleOperationForStoredProcedure(operations)) + // Currently, Stored Procedures can be configured with only Execute Operation. + bool isStoredProcedure = sourceType is SourceType.StoredProcedure; + if (isStoredProcedure && !VerifyExecuteOperationForStoredProcedure(operations)) { return false; } @@ -464,11 +462,16 @@ public static bool VerifyOperations(string[] operations, SourceType sourceType) { containsWildcardOperation = true; } - else if (!PermissionOperation.ValidPermissionOperations.Contains(op)) + else if (!isStoredProcedure && !PermissionOperation.ValidPermissionOperations.Contains(op)) { _logger.LogError("Invalid actions found in --permissions"); return false; } + else if (isStoredProcedure && !PermissionOperation.ValidStoredProcedurePermissionOperations.Contains(op)) + { + _logger.LogError("Invalid stored procedure action(s) found in --permissions"); + return false; + } } else { @@ -700,7 +703,7 @@ public static bool VerifyPermissionOperationsForStoredProcedures( { foreach (PermissionSetting permissionSetting in permissionSettings) { - if (!VerifySingleOperationForStoredProcedure(permissionSetting.Operations)) + if (!VerifyExecuteOperationForStoredProcedure(permissionSetting.Operations)) { return false; } @@ -711,15 +714,15 @@ public static bool VerifyPermissionOperationsForStoredProcedures( /// /// This method checks that stored-procedure entity - /// has only one CRUD operation. + /// is configured only with execute action /// - private static bool VerifySingleOperationForStoredProcedure(object[] operations) + private static bool VerifyExecuteOperationForStoredProcedure(object[] operations) { if (operations.Length > 1 || !TryGetOperationName(operations.First(), out Operation operationName) - || Operation.All.Equals(operationName)) + || operationName is not Operation.Execute) { - _logger.LogError("Stored Procedure supports only 1 CRUD operation."); + _logger.LogError("Stored Procedure supports only execute operation."); return false; } @@ -820,5 +823,236 @@ public static bool WriteJsonContentToFile(string file, string jsonContent) return true; } + + /// + /// Utility method that converts REST HTTP verb string input to RestMethod Enum. + /// The method returns true/false corresponding to successful/unsuccessful conversion. + /// + /// String input entered by the user + /// RestMethod Enum type + /// + public static bool TryConvertRestMethodNameToRestMethod(string? method, out RestMethod restMethod) + { + if (!Enum.TryParse(method, ignoreCase: true, out restMethod)) + { + _logger.LogError($"Invalid REST Method. Supported methods are {RestMethod.Get.ToString()}, {RestMethod.Post.ToString()} , {RestMethod.Put.ToString()}, {RestMethod.Patch.ToString()} and {RestMethod.Delete.ToString()}."); + return false; + } + + return true; + } + + /// + /// Utility method that converts list of REST HTTP verbs configured for a + /// stored procedure into an array of RestMethod Enum type. + /// If any invalid REST methods are supplied, an empty array is returned. + /// + /// Collection of REST HTTP verbs configured for the stored procedure + /// REST methods as an array of RestMethod Enum type. + public static RestMethod[] CreateRestMethods(IEnumerable methods) + { + List restMethods = new(); + + foreach (string method in methods) + { + RestMethod restMethod; + if (TryConvertRestMethodNameToRestMethod(method, out restMethod)) + { + restMethods.Add(restMethod); + } + else + { + restMethods.Clear(); + break; + } + + } + + return restMethods.ToArray(); + } + + /// + /// Utility method that converts the graphQL operation configured for the stored procedure to + /// GraphQLOperation Enum type. + /// The metod returns true/false corresponding to successful/unsuccessful conversion. + /// + /// GraphQL operation configured for the stored procedure + /// GraphQL Operation as an Enum type + /// true/false + public static bool TryConvertGraphQLOperationNameToGraphQLOperation(string? operation, [NotNullWhen(true)] out GraphQLOperation graphQLOperation) + { + if (!Enum.TryParse(operation, ignoreCase: true, out graphQLOperation)) + { + _logger.LogError($"Invalid GraphQL Operation. Supported operations are {GraphQLOperation.Query.ToString()} and {GraphQLOperation.Mutation.ToString()}."); + return false; + } + + return true; + } + + /// + /// Method to check if the options for an entity represent a stored procedure + /// + /// + /// + public static bool IsStoredProcedure(EntityOptions options) + { + SourceTypeEnumConverter.TryGetSourceType(options.SourceType, out SourceType sourceObjectType); + return sourceObjectType is SourceType.StoredProcedure; + } + + /// + /// Method to determine whether the type of an entity is being converted from stored-procedure to + /// table/view. + /// + /// + /// + public static bool IsStoredProcedure(Entity entity) + { + return entity.ObjectType is SourceType.StoredProcedure; + } + + /// + /// Method to determine if the type of the entity is being converted from + /// stored-procedure to table/view. + /// + /// Entity for which the source type conversion is being determined + /// Options from the CLI commands + /// True when an entity of type stored-procedure is converted to a table/view + public static bool IsStoredProcedureConvertedToOtherTypes(Entity entity, EntityOptions options) + { + if (options.SourceType is null) + { + return false; + } + + bool isCurrentEntityStoredProcedure = IsStoredProcedure(entity); + bool doOptionsRepresentStoredProcedure = options.SourceType is not null && IsStoredProcedure(options); + return isCurrentEntityStoredProcedure && !doOptionsRepresentStoredProcedure; + } + + /// + /// Method to determine whether the type of an entity is being changed from + /// table/view to stored-procedure. + /// + /// Entity for which the source type conversion is being determined + /// Options from the CLI commands + /// True when an entity of type table/view is converted to a stored-procedure + public static bool IsEntityBeingConvertedToStoredProcedure(Entity entity, EntityOptions options) + { + if (options.SourceType is null) + { + return false; + } + + bool isCurrentEntityStoredProcedure = IsStoredProcedure(entity); + bool doOptionsRepresentStoredProcedure = options.SourceType is not null && IsStoredProcedure(options); + return !isCurrentEntityStoredProcedure && doOptionsRepresentStoredProcedure; + } + + /// + /// For stored procedures, the rest HTTP verbs to be supported can be configured using + /// --rest.methods option. + /// Validation to ensure that configuring REST methods for a stored procedure that is + /// not enabled for REST results in an error. This validation is run along + /// with add command. + /// + /// Options entered using add command + /// True for invalid conflicting REST options. False when the options are valid + public static bool CheckConflictingRestConfigurationForStoredProcedures(EntityOptions options) + { + return (options.RestRoute is not null && bool.TryParse(options.RestRoute, out bool restEnabled) && !restEnabled) && + (options.RestMethodsForStoredProcedure is not null && options.RestMethodsForStoredProcedure.Any()); + } + + /// + /// For stored procedures, the graphql operation to be supported can be configured using + /// --graphql.operation. + /// Validation to ensure that configuring GraphQL operation for a stored procedure that is + /// not exposed for graphQL results in an error. This validation is run along with add + /// command + /// + /// + /// True for invalid conflicting graphQL options. False when the options are not conflicting + public static bool CheckConflictingGraphQLConfigurationForStoredProcedures(EntityOptions options) + { + return (options.GraphQLType is not null && bool.TryParse(options.GraphQLType, out bool graphQLEnabled) && !graphQLEnabled) + && (options.GraphQLOperationForStoredProcedure is not null); + } + + /// + /// Constructs the REST Path using the add/update command --rest option + /// + /// Input entered using --rest option + /// Constructed REST Path + public static object? ConstructRestPathDetails(string? restRoute) + { + object? restPath; + if (restRoute is null) + { + restPath = null; + } + else + { + if (bool.TryParse(restRoute, out bool restEnabled)) + { + restPath = restEnabled; + } + else + { + restPath = "/" + restRoute; + } + } + + return restPath; + } + + /// + /// Constructs the graphQL Type from add/update command --graphql option + /// + /// GraphQL type input from the CLI commands + /// Constructed GraphQL Type + public static object? ConstructGraphQLTypeDetails(string? graphQL) + { + object? graphQLType; + bool graphQLEnabled; + if (graphQL is null) + { + graphQLType = null; + } + else + { + if (bool.TryParse(graphQL, out graphQLEnabled)) + { + graphQLType = graphQLEnabled; + } + else + { + string singular, plural; + if (graphQL.Contains(SEPARATOR)) + { + string[] arr = graphQL.Split(SEPARATOR); + if (arr.Length != 2) + { + _logger.LogError($"Invalid format for --graphql. Accepted values are true/false," + + "a string, or a pair of string in the format :"); + return null; + } + + singular = arr[0]; + plural = arr[1]; + } + else + { + singular = graphQL.Singularize(inputIsKnownToBePlural: false); + plural = graphQL.Pluralize(inputIsKnownToBeSingular: false); + } + + graphQLType = new SingularPlural(singular, plural); + } + } + + return graphQLType; + } } } diff --git a/src/Cli/test/AddEntityTests.cs b/src/Cli/test/AddEntityTests.cs index ff7830d3f3..41b1f5b695 100644 --- a/src/Cli/test/AddEntityTests.cs +++ b/src/Cli/test/AddEntityTests.cs @@ -36,7 +36,10 @@ public void AddNewEntityWhenEntitiesEmpty() fieldsToExclude: new string[] { }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string initialConfiguration = INITIAL_CONFIG; string expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, GetFirstEntityConfiguration()); @@ -62,7 +65,10 @@ public void AddNewEntityWhenEntitiesNotEmpty() fieldsToExclude: new string[] { }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string initialConfiguration = AddPropertiesToJson(INITIAL_CONFIG, GetFirstEntityConfiguration()); string configurationWithOneEntity = AddPropertiesToJson(INITIAL_CONFIG, GetFirstEntityConfiguration()); @@ -90,7 +96,10 @@ public void AddDuplicateEntity() fieldsToExclude: null, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string initialConfiguration = AddPropertiesToJson(INITIAL_CONFIG, GetFirstEntityConfiguration()); Assert.IsFalse(ConfigGenerator.TryAddNewEntity(options, ref initialConfiguration)); @@ -116,7 +125,9 @@ public void AddEntityWithAnExistingNameButWithDifferentCase() fieldsToExclude: new string[] { }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string initialConfiguration = AddPropertiesToJson(INITIAL_CONFIG, GetFirstEntityConfiguration()); @@ -151,7 +162,9 @@ public void AddEntityWithPolicyAndFieldProperties(IEnumerable? fieldsToI fieldsToExclude: fieldsToExclude, policyRequest: policyRequest, policyDatabase: policyDatabase, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string? expectedConfiguration = null; @@ -179,7 +192,7 @@ public void AddNewEntityWhenEntitiesWithSourceAsStoredProcedure() { AddOptions options = new( source: "s001.book", - permissions: new string[] { "anonymous", "read" }, + permissions: new string[] { "anonymous", "execute" }, entity: "MyEntity", sourceType: "stored-procedure", sourceParameters: new string[] { "param1:123", "param2:hello", "param3:true" }, @@ -190,20 +203,54 @@ public void AddNewEntityWhenEntitiesWithSourceAsStoredProcedure() fieldsToExclude: new string[] { }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string initialConfiguration = INITIAL_CONFIG; string expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); RunTest(options, initialConfiguration, expectedConfiguration); } + /// + /// Tests that the CLI Add command translates the user provided options into the expected configuration file. + /// This test validates that the stored procedure entity configuration JSON contains the execute permission as well as + /// the explicitly configured REST methods (Post, Put, Patch) and GraphQL operation (Query). + /// + [TestMethod] + public void TestAddStoredProcedureWithRestMethodsAndGraphQLOperations() + { + AddOptions options = new( + source: "s001.book", + permissions: new string[] { "anonymous", "execute" }, + entity: "MyEntity", + sourceType: "stored-procedure", + sourceParameters: new string[] { "param1:123", "param2:hello", "param3:true" }, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: new string[] { }, + fieldsToExclude: new string[] { }, + policyRequest: null, + policyDatabase: null, + config: _testRuntimeConfig, + restMethodsForStoredProcedure: new string[] { "Post", "Put", "Patch" }, + graphQLOperationForStoredProcedure: "Query" + ); + + string initialConfiguration = INITIAL_CONFIG; + string expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE_WITH_CUSTOM_REST_GRAPHQL_CONFIG); + RunTest(options, initialConfiguration, expectedConfiguration); + } + /// /// Simple test to verify success on adding a new entity with source object for valid fields. /// [DataTestMethod] [DataRow(null, null, null, "*", true, DisplayName = "Both KeyFields and Parameters not provided for source")] - [DataRow("stored-procedure", new string[] { "param1:value1" }, null, "create", true, DisplayName = "SourceParameters correctly included with stored procedure")] - [DataRow("Stored-Procedure", new string[] { "param1:value1" }, null, "read", true, DisplayName = "Stored procedure type check for Case Insensitivity")] + [DataRow("stored-procedure", new string[] { "param1:value1" }, null, "execute", true, DisplayName = "SourceParameters correctly included with stored procedure")] + [DataRow("Stored-Procedure", new string[] { "param1:value1" }, null, "execute", true, DisplayName = "Stored procedure type check for Case Insensitivity")] [DataRow("stored-procedure", new string[] { "param1:value1" }, null, "*", false, DisplayName = "Stored procedure incorrectly configured with wildcard CRUD action")] [DataRow("view", null, new string[] { "col1", "col2" }, "*", true, DisplayName = "Source KeyFields correctly included with with View")] [DataRow("table", null, new string[] { "col1", "col2" }, "*", true, DisplayName = "Source KeyFields correctly included with with Table")] @@ -234,13 +281,76 @@ public void TestAddNewEntityWithSourceObjectHavingValidFields( fieldsToExclude: new string[] { }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = INITIAL_CONFIG; Assert.AreEqual(expectSuccess, ConfigGenerator.TryAddNewEntity(options, ref runtimeConfig)); } + /// + /// Validates the successful/unsuccessful execution of ConfigGenerator.TryAddNewEntity() + /// by passing AddOptions for a stored procedure with various combinations of REST Path, REST Methods, + /// GraphQL Type, and GraphQL Operation. + /// Failure is limited to when GraphQL and REST explicit options are provided, but the associated + /// REST/GraphQL endpoint for the entity is disabled. + /// + /// Explicitly configured REST methods for stored procedure. + /// Explicitly configured GraphQL operation for stored procedure (Query/Mutation). + /// Custom REST route + /// Whether GraphQL is explicitly enabled/disabled on the entity. + /// Whether adding the specified option is expected to succeed. (True/false). + [DataTestMethod] + [DataRow(null, null, null, null, true, DisplayName = "Default Case without any customization")] + [DataRow(null, null, "true", null, true, DisplayName = "REST enabled without any methods explicitly configured")] + [DataRow(null, null, "book", null, true, DisplayName = "Custom REST path defined without any methods explictly configured")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, null, null, true, DisplayName = "REST methods defined without REST Path explicitly configured")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, "true", null, true, DisplayName = "REST enabled along with some methods")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, "book", null, true, DisplayName = "Custom REST path defined along with some methods")] + [DataRow(null, null, null, "true", true, DisplayName = "GraphQL enabled without any operation explicitly configured")] + [DataRow(null, null, null, "book", true, DisplayName = "Custom GraphQL Type defined without any operation explicitly configured")] + [DataRow(null, null, null, "book:books", true, DisplayName = "SingularPlural GraphQL Type enabled without any operation explicitly configured")] + [DataRow(null, "Query", null, "true", true, DisplayName = "GraphQL enabled with Query operation")] + [DataRow(null, "Query", null, "book", true, DisplayName = "Custom GraphQL Type defined along with Query operation")] + [DataRow(null, "Query", null, "book:books", true, DisplayName = "SingularPlural GraphQL Type defined along with Query operation")] + [DataRow(null, null, null, "true", true, DisplayName = "Both REST and GraphQL enabled without any methods and operations configured explicitly")] + [DataRow(new string[] { "Get" }, "Query", "true", "true", true, DisplayName = "Both REST and GraphQL enabled with custom REST methods and GraphQL operations")] + [DataRow(new string[] { "Post,Patch,Put" }, "Query", "book", "book:books", true, DisplayName = "Configuration with REST Path, Methods and GraphQL Type, Operation")] + [DataRow(null, "Mutation", "true", "false", false, DisplayName = "Conflicting configurations - GraphQL operation specified but entity is disabled for GraphQL")] + [DataRow(new string[] { "Get" }, null, "false", "true", false, DisplayName = "Conflicting configurations - REST methods specified but entity is disabled for REST")] + public void TestAddNewSpWithDifferentRestAndGraphQLOptions( + IEnumerable? restMethods, + string? graphQLOperation, + string? restRoute, + string? graphQLType, + bool expectSuccess + ) + { + AddOptions options = new( + source: "testSource", + permissions: new string[] { "anonymous", "execute" }, + entity: "book", + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: restRoute, + graphQLType: graphQLType, + fieldsToInclude: new string[] { }, + fieldsToExclude: new string[] { }, + policyRequest: null, + policyDatabase: null, + config: _testRuntimeConfig, + restMethodsForStoredProcedure: restMethods, + graphQLOperationForStoredProcedure: graphQLOperation + ); + + string runtimeConfig = INITIAL_CONFIG; + Assert.AreEqual(expectSuccess, ConfigGenerator.TryAddNewEntity(options, ref runtimeConfig)); + } + /// /// Check failure when adding an entity with permission containing invalid operations /// @@ -268,7 +378,10 @@ public void TestAddEntityPermissionWithInvalidOperation(IEnumerable perm fieldsToExclude: new string[] { "level" }, policyRequest: null, policyDatabase: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = INITIAL_CONFIG; @@ -287,7 +400,6 @@ private static void RunTest(AddOptions options, string initialConfig, string exp JObject expectedJson = JObject.Parse(expectedConfig); JObject actualJson = JObject.Parse(initialConfig); - Assert.IsTrue(JToken.DeepEquals(expectedJson, actualJson)); } diff --git a/src/Cli/test/EndToEndTests.cs b/src/Cli/test/EndToEndTests.cs index 5629b4be4a..d372b09d7f 100644 --- a/src/Cli/test/EndToEndTests.cs +++ b/src/Cli/test/EndToEndTests.cs @@ -86,8 +86,6 @@ public void TestAddEntity() Program.Main(initArgs); RuntimeConfig? runtimeConfig = TryGetRuntimeConfig(_testRuntimeConfig); - runtimeConfig!.DetermineGlobalSettings(); - runtimeConfig!.DetermineGraphQLEntityNames(); // Perform assertions on various properties. Assert.IsNotNull(runtimeConfig); @@ -233,7 +231,7 @@ public void TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure() RuntimeConfig? runtimeConfig = TryGetRuntimeConfig(_testRuntimeConfig); Assert.IsNotNull(runtimeConfig); Assert.AreEqual(0, runtimeConfig.Entities.Count()); // No entities - string[] addArgs = { "add", "MyEntity", "-c", _testRuntimeConfig, "--source", "s001.book", "--permissions", "anonymous:read", "--source.type", "stored-procedure", "--source.params", "param1:123,param2:hello,param3:true" }; + string[] addArgs = { "add", "MyEntity", "-c", _testRuntimeConfig, "--source", "s001.book", "--permissions", "anonymous:execute", "--source.type", "stored-procedure", "--source.params", "param1:123,param2:hello,param3:true" }; Program.Main(addArgs); string? actualConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); Assert.IsTrue(JToken.DeepEquals(JObject.Parse(actualConfig), JObject.Parse(File.ReadAllText(_testRuntimeConfig)))); @@ -274,14 +272,57 @@ public void TestConfigGeneratedAfterUpdatingEntityWithSourceAsStoredProcedure() ""param1"": 123, ""param2"": ""hello"", ""param3"": true - }, - ""key-fields"": [] + } }"; actualSourceObject = JsonSerializer.Serialize(runtimeConfig.Entities["MyEntity"].Source); Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedSourceObject), JObject.Parse(actualSourceObject))); } + /// + /// Validates that the built JSON configuration contains the explicit stored procedure entity settings + /// --rest.methods and --graphql.operations. + /// + [TestMethod] + public void TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations() + { + string[] initArgs = { "init", "-c", _testRuntimeConfig, "--database-type", "mssql", + "--host-mode", "Development", "--connection-string", "testconnectionstring", "--set-session-context", "true" }; + Program.Main(initArgs); + RuntimeConfig? runtimeConfig = TryGetRuntimeConfig(_testRuntimeConfig); + Assert.IsNotNull(runtimeConfig); + Assert.AreEqual(0, runtimeConfig.Entities.Count()); // No entities + string[] addArgs = { "add", "MyEntity", "-c", _testRuntimeConfig, "--source", "s001.book", "--permissions", "anonymous:execute", "--source.type", "stored-procedure", "--source.params", "param1:123,param2:hello,param3:true", "--rest.methods", "post,put,patch", "--graphql.operation", "query" }; + Program.Main(addArgs); + string? expectedConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE_WITH_CUSTOM_REST_GRAPHQL_CONFIG); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedConfig), JObject.Parse(File.ReadAllText(_testRuntimeConfig)))); + } + + /// + /// Validates that CLI execution of the add/update commands results in a stored procedure entity + /// with explicit rest method GET and GraphQL endpoint disabled. + /// + [TestMethod] + public void TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations() + { + string[] initArgs = { "init", "-c", _testRuntimeConfig, "--database-type", "mssql", + "--host-mode", "Development", "--connection-string", "testconnectionstring", "--set-session-context", "true" }; + Program.Main(initArgs); + RuntimeConfig? runtimeConfig = TryGetRuntimeConfig(_testRuntimeConfig); + Assert.IsNotNull(runtimeConfig); + Assert.AreEqual(0, runtimeConfig.Entities.Count()); // No entities + + string[] addArgs = { "add", "MyEntity", "-c", _testRuntimeConfig, "--source", "s001.book", "--permissions", "anonymous:execute", "--source.type", "stored-procedure", "--source.params", "param1:123,param2:hello,param3:true", "--rest.methods", "post,put,patch", "--graphql.operation", "query" }; + Program.Main(addArgs); + string? expectedConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE_WITH_CUSTOM_REST_GRAPHQL_CONFIG); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedConfig), JObject.Parse(File.ReadAllText(_testRuntimeConfig)))); + + string[] updateArgs = { "update", "MyEntity", "-c", _testRuntimeConfig, "--rest.methods", "get", "--graphql", "false" }; + Program.Main(updateArgs); + expectedConfig = AddPropertiesToJson(INITIAL_CONFIG, STORED_PROCEDURE_WITH_REST_GRAPHQL_CONFIG); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedConfig), JObject.Parse(File.ReadAllText(_testRuntimeConfig)))); + } + /// /// Test the exact config json generated to verify adding a new Entity with default source type and given key-fields. /// diff --git a/src/Cli/test/TestHelper.cs b/src/Cli/test/TestHelper.cs index f9eaffd4e1..b873e1e357 100644 --- a/src/Cli/test/TestHelper.cs +++ b/src/Cli/test/TestHelper.cs @@ -236,12 +236,86 @@ public static Process ExecuteDabCommand(string command, string flags) { ""role"": ""anonymous"", ""actions"": [ - ""read"" + ""execute"" ] } - ] - } - } + ], + ""rest"": { + ""methods"": [ + ""post"" + ] + }, + ""graphql"": { + ""operation"": ""Mutation"" + } + } + } + }"; + + public const string SINGLE_ENTITY_WITH_STORED_PROCEDURE_WITH_CUSTOM_REST_GRAPHQL_CONFIG = @" + { + ""entities"": { + ""MyEntity"": { + ""source"": { + ""type"": ""stored-procedure"", + ""object"": ""s001.book"", + ""parameters"": { + ""param1"": 123, + ""param2"": ""hello"", + ""param3"": true + } + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + ""execute"" + ] + } + ], + ""rest"": { + ""methods"": [ + ""post"", + ""put"", + ""patch"" + ] + }, + ""graphql"": { + ""operation"": ""Query"" + } + } + } + }"; + + public const string STORED_PROCEDURE_WITH_REST_GRAPHQL_CONFIG = @" + { + ""entities"": { + ""MyEntity"": { + ""source"": { + ""type"": ""stored-procedure"", + ""object"": ""s001.book"", + ""parameters"": { + ""param1"": 123, + ""param2"": ""hello"", + ""param3"": true + } + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + ""execute"" + ] + } + ], + ""rest"": { + ""methods"": [ + ""get"" + ] + }, + ""graphql"": false + } + } }"; public const string SINGLE_ENTITY_WITH_SOURCE_AS_TABLE = @" diff --git a/src/Cli/test/UpdateEntityTests.cs b/src/Cli/test/UpdateEntityTests.cs index a33f233f33..ac08e9ad61 100644 --- a/src/Cli/test/UpdateEntityTests.cs +++ b/src/Cli/test/UpdateEntityTests.cs @@ -44,7 +44,10 @@ public void TestUpdateEntityPermission() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null) + ; string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -117,7 +120,10 @@ public void TestUpdateEntityPermissionByAddingNewRole() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -191,7 +197,10 @@ public void TestUpdateEntityPermissionWithExistingAction() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -262,7 +271,10 @@ public void TestUpdateEntityPermissionHavingWildcardAction() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -361,7 +373,10 @@ public void TestUpdateEntityPermissionWithWildcardAction() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -430,7 +445,10 @@ public void TestUpdateEntityByAddingNewRelationship() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -542,7 +560,10 @@ public void TestUpdateEntityByModifyingRelationship() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -663,7 +684,10 @@ public void TestCreateNewRelationship() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); Relationship? relationship = CreateNewRelationshipWithUpdateOptions(options); @@ -705,7 +729,10 @@ public void TestCreateNewRelationshipWithMultipleLinkingFields() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); Relationship? relationship = CreateNewRelationshipWithUpdateOptions(options); @@ -747,7 +774,10 @@ public void TestCreateNewRelationshipWithMultipleRelationshipFields() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); Relationship? relationship = CreateNewRelationshipWithUpdateOptions(options); @@ -797,7 +827,9 @@ public void TestUpdateEntityWithPolicyAndFieldProperties(IEnumerable? fi linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { }, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string? actualConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY); @@ -824,7 +856,6 @@ public void TestUpdateEntityWithPolicyAndFieldProperties(IEnumerable? fi /// [DataTestMethod] [DataRow("s001.book", null, new string[] { "anonymous", "*" }, null, null, "UpdateSourceName", DisplayName = "Updating sourceName with no change in parameters or keyfields.")] - [DataRow(null, "stored-procedure", null, new string[] { "param1:123", "param2:hello", "param3:true" }, null, "ConvertToStoredProcedure", DisplayName = "SourceParameters with stored procedure.")] [DataRow(null, "view", null, null, new string[] { "col1", "col2" }, "ConvertToView", DisplayName = "Source KeyFields with View")] [DataRow(null, "table", null, null, new string[] { "id", "name" }, "ConvertToTable", DisplayName = "Source KeyFields with Table")] [DataRow(null, null, null, null, new string[] { "id", "name" }, "ConvertToDefaultType", DisplayName = "Source KeyFields with SourceType not provided")] @@ -858,7 +889,9 @@ public void TestUpdateSourceStringToDatabaseSourceObject( linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { }, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string? actualConfig = AddPropertiesToJson(INITIAL_CONFIG, BASIC_ENTITY_WITH_ANONYMOUS_ROLE); @@ -869,10 +902,6 @@ public void TestUpdateSourceStringToDatabaseSourceObject( actualConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY); expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, BASIC_ENTITY_WITH_ANONYMOUS_ROLE); break; - case "ConvertToStoredProcedure": - actualConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_ONLY_READ_PERMISSION); - expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); - break; case "ConvertToView": expectedConfiguration = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_SOURCE_AS_VIEW); break; @@ -886,21 +915,27 @@ public void TestUpdateSourceStringToDatabaseSourceObject( } /// - /// Simple test to verify success on updating a source's value type from string to object. + /// Validate behavior of updating a source's value type from string to object. /// + /// Name of database object. + /// Stored Procedure Parameters + /// Primary key fields + /// Permissions role:action + /// Denotes which test/assertion is made on updated entity. [DataTestMethod] - [DataRow("newSourceName", null, null, "UpdateSourceName", DisplayName = "Update Source Name of the source object.")] - [DataRow(null, new string[] { "param1:dab", "param2:false" }, null, "UpdateParameters", DisplayName = "update Parameters of stored procedure.")] - [DataRow(null, null, new string[] { "col1", "col2" }, "UpdateKeyFields", DisplayName = "update KeyFields for table/view.")] + [DataRow("newSourceName", null, null, new string[] { "anonymous", "execute" }, "UpdateSourceName", DisplayName = "Update Source Name of the source object.")] + [DataRow(null, new string[] { "param1:dab", "param2:false" }, null, new string[] { "anonymous", "execute" }, "UpdateParameters", DisplayName = "Update Parameters of stored procedure.")] + [DataRow(null, null, new string[] { "col1", "col2" }, new string[] { "anonymous", "read" }, "UpdateKeyFields", DisplayName = "Update KeyFields for table/view.")] public void TestUpdateDatabaseSourceObject( string? source, IEnumerable? parameters, IEnumerable? keyFields, + IEnumerable? permissionConfig, string task) { UpdateOptions options = new( source: source, - permissions: new string[] { "anonymous", "read" }, + permissions: permissionConfig, entity: "MyEntity", sourceType: null, sourceParameters: parameters, @@ -919,7 +954,9 @@ public void TestUpdateDatabaseSourceObject( linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { }, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string? initialConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); @@ -984,10 +1021,10 @@ public void TestUpdateDatabaseSourceObject( /// [DataTestMethod] [DataRow(SINGLE_ENTITY_WITH_ONLY_READ_PERMISSION, "stored-procedure", new string[] { "param1:123", "param2:hello", "param3:true" }, - null, SINGLE_ENTITY_WITH_STORED_PROCEDURE, null, false, true, + null, SINGLE_ENTITY_WITH_STORED_PROCEDURE, new string[] { "anonymous", "execute" }, false, true, DisplayName = "PASS:Convert table to stored-procedure with valid parameters.")] [DataRow(SINGLE_ENTITY_WITH_SOURCE_AS_TABLE, "stored-procedure", null, new string[] { "col1", "col2" }, - SINGLE_ENTITY_WITH_STORED_PROCEDURE, new string[] { "anonymous", "read" }, false, false, + SINGLE_ENTITY_WITH_STORED_PROCEDURE, new string[] { "anonymous", "execute" }, false, false, DisplayName = "FAIL:Convert table to stored-procedure with invalid KeyFields.")] [DataRow(SINGLE_ENTITY_WITH_SOURCE_AS_TABLE, "stored-procedure", null, null, SINGLE_ENTITY_WITH_STORED_PROCEDURE, null, true, false, DisplayName = "FAIL:Convert table with wildcard CRUD operation to stored-procedure.")] @@ -1039,7 +1076,9 @@ public void TestConversionOfSourceObject( linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { }, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string runtimeConfig = AddPropertiesToJson(INITIAL_CONFIG, initialSourceObjectEntity); @@ -1133,7 +1172,9 @@ public void TestUpdatePolicy() linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { }, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string? actualConfig = AddPropertiesToJson(INITIAL_CONFIG, ENTITY_CONFIG_WITH_POLCIY_AND_ACTION_FIELDS); @@ -1171,17 +1212,16 @@ public void TestUpdatePolicy() /// /// Test to verify updating permissions for stored-procedure. /// Checks: - /// 1. Updating with WILDCARD/multiple CRUD action should fail. - /// 2. Adding a new role should have the same single CRUD operation as the existing one. + /// 1. Updating a stored-procedure with WILDCARD/CRUD action should fail. + /// 2. Adding a new role/Updating an existing role with execute action should succeeed. /// [DataTestMethod] - [DataRow("anonymous", "*", false, DisplayName = "FAIL: Stored-Procedure incorrectly updated with wildcard CRUD operation for an existing role.")] - [DataRow("anonymous", "create", true, DisplayName = "PASS: Stored-Procedure with 1 CRUD operation for an existing role.")] - [DataRow("anonymous", "create,read", false, DisplayName = "FAIL: Stored-Procedure with more than 1 CRUD operation for an existing role.")] - [DataRow("authenticated", "*", false, DisplayName = "FAIL: Stored-Procedure with wildcard CRUD operation for a new role.")] - [DataRow("authenticated", "create", true, DisplayName = "PASS: Stored-Procedure with 1 CRUD operation for a new role.")] - [DataRow("authenticated", "read", true, DisplayName = "PASS: Stored-Procedure with same single CRUD operation for a new role as that of existing one.")] - [DataRow("authenticated", "create,read", false, DisplayName = "FAIL: Stored-Procedure with more than 1 CRUD operation for a new role.")] + [DataRow("anonymous", "*", false, DisplayName = "FAIL: Stored-Procedure updated with wildcard operation")] + [DataRow("anonymous", "execute", true, DisplayName = "PASS: Stored-Procedure updated with execute operation")] + [DataRow("anonymous", "create,read", false, DisplayName = "FAIL: Stored-Procedure updated with CRUD action.")] + [DataRow("authenticated", "*", false, DisplayName = "FAIL: Stored-Procedure updated with wildcard operation for an existing role.")] + [DataRow("authenticated", "execute", true, DisplayName = "PASS: Stored-Procedure updated with execute operation for an existing role.")] + [DataRow("authenticated", "create,read", false, DisplayName = "FAIL: Stored-Procedure updated with CRUD action for an existing role.")] public void TestUpdatePermissionsForStoredProcedure( string role, string operations, @@ -1209,7 +1249,10 @@ bool isSuccess policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); @@ -1243,7 +1286,10 @@ public void TestUpdateEntityWithMappings() linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { "id:Identity", "name:Company Name" }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -1282,7 +1328,8 @@ public void TestUpdateEntityWithMappings() } /// - /// Test to Update stored procedure action + /// Test to Update stored procedure action. Stored procedures support only execute action. + /// An attempt to update to another action should be unsuccessful. /// [TestMethod] public void TestUpdateActionOfStoredProcedureRole() @@ -1308,7 +1355,10 @@ public void TestUpdateActionOfStoredProcedureRole() linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -1321,13 +1371,13 @@ public void TestUpdateActionOfStoredProcedureRole() { ""role"": ""anonymous"", ""actions"": [ - ""read"" + ""execute"" ] }, { ""role"": ""authenticated"", ""actions"": [ - ""read"" + ""execute"" ] } ] @@ -1335,33 +1385,7 @@ public void TestUpdateActionOfStoredProcedureRole() } }"; - string expectedConfig = GetInitialConfigString() + "," + @" - ""entities"": { - ""MyEntity"": { - ""source"": { - ""object"": ""MySp"", - ""type"": ""stored-procedure"" - }, - ""permissions"": [ - { - ""role"": ""anonymous"", - ""actions"": [ - ""read"" - ] - }, - { - ""role"": ""authenticated"", - ""actions"": [ - ""create"" - ] - } - ] - } - } - }"; - - Assert.IsTrue(ConfigGenerator.TryUpdateExistingEntity(options, ref runtimeConfig)); - Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedConfig), JObject.Parse(runtimeConfig))); + Assert.IsFalse(ConfigGenerator.TryUpdateExistingEntity(options, ref runtimeConfig)); } /// @@ -1391,7 +1415,10 @@ public void TestUpdateEntityWithSpecialCharacterInMappings() linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { "Macaroni:Mac & Cheese", "region:United State's Region", "russian:русский", "chinese:中文" }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -1458,7 +1485,10 @@ public void TestUpdateExistingMappings() linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: new string[] { "name:Company Name", "addr:Company Address", "number:Contact Details" }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetConfigWithMappings(); @@ -1485,6 +1515,94 @@ public void TestUpdateExistingMappings() Assert.IsTrue(JToken.DeepEquals(JObject.Parse(expectedConfig), JObject.Parse(runtimeConfig))); } + /// + /// Test to validate various updates to various combinations of + /// REST path, REST methods, GraphQL Type and GraphQL Operation are working as intended. + /// + /// List of REST Methods that are configured for the entity + /// GraphQL Operation configured for the entity + /// REST Path configured for the entity + /// GraphQL Type configured for the entity + /// Should update with the options succeed + [DataTestMethod] + [DataRow(null, null, null, null, true, DisplayName = "Default Case without any customization")] + [DataRow(null, null, "true", null, true, DisplayName = "REST enabled without any methods explicitly configured")] + [DataRow(null, null, "book", null, true, DisplayName = "Custom REST path defined without any methods explictly configured")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, null, null, true, DisplayName = "REST methods defined without REST Path explicitly configured")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, "true", null, true, DisplayName = "REST enabled along with some methods")] + [DataRow(new string[] { "Get", "Post", "Patch" }, null, "book", null, true, DisplayName = "Custom REST path defined along with some methods")] + [DataRow(null, null, null, "true", true, DisplayName = "GraphQL enabled without any operation explicitly configured")] + [DataRow(null, null, null, "book", true, DisplayName = "Custom GraphQL Type defined without any operation explicitly configured")] + [DataRow(null, null, null, "book:books", true, DisplayName = "SingularPlural GraphQL Type enabled without any operation explicitly configured")] + [DataRow(null, "Query", null, "true", true, DisplayName = "GraphQL enabled with Query operation")] + [DataRow(null, "Query", null, "book", true, DisplayName = "Custom GraphQL Type defined along with Query operation")] + [DataRow(null, "Query", null, "book:books", true, DisplayName = "SingularPlural GraphQL Type defined along with Query operation")] + [DataRow(null, null, null, "true", true, DisplayName = "Both REST and GraphQL enabled without any methods and operations configured explicitly")] + [DataRow(new string[] { "Get" }, "Query", "true", "true", true, DisplayName = "Both REST and GraphQL enabled with custom REST methods and GraphQL operations")] + [DataRow(new string[] { "Post,Patch,Put" }, "Query", "book", "book:books", true, DisplayName = "Configuration with REST Path, Methods and GraphQL Type, Operation")] + [DataRow(null, "Mutation", "true", "false", false, DisplayName = "Conflicting configurations - GraphQL operation specified but entity is disabled for GraphQL")] + [DataRow(new string[] { "Get" }, null, "false", "true", false, DisplayName = "Conflicting configurations - REST methods specified but entity is disabled for REST")] + public void TestUpdateRestAndGraphQLSettingsForStoredProcedures( + IEnumerable? restMethods, + string? graphQLOperation, + string? restRoute, + string? graphQLType, + bool expectSuccess) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: restRoute, + graphQLType: graphQLType, + fieldsToInclude: new string[] { }, + fieldsToExclude: new string[] { }, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: new string[] { }, + linkingTargetFields: new string[] { }, + relationshipFields: new string[] { }, + map: null, + config: _testRuntimeConfig, + restMethodsForStoredProcedure: restMethods, + graphQLOperationForStoredProcedure: graphQLOperation + ); + + string runtimeConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": { + ""object"": ""MySp"", + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [ + ""execute"" + ] + }, + { + ""role"": ""authenticated"", + ""actions"": [ + ""execute"" + ] + } + ] + } + } + }"; + + Assert.AreEqual(expectSuccess, ConfigGenerator.TryUpdateExistingEntity(options, ref runtimeConfig)); + } + #endregion #region Negative Tests @@ -1518,7 +1636,10 @@ public void TestUpdateEntityPermissionWithWildcardAndOtherCRUDAction() policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -1580,7 +1701,10 @@ public void TestUpdateSourceObjectWithInvalidFields( policyRequest: null, policyDatabase: null, map: new string[] { }, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = AddPropertiesToJson(INITIAL_CONFIG, SINGLE_ENTITY_WITH_STORED_PROCEDURE); @@ -1630,7 +1754,10 @@ public void TestCreateNewRelationshipWithInvalidRelationshipFields() policyRequest: null, policyDatabase: null, map: null, - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); Relationship? relationship = CreateNewRelationshipWithUpdateOptions(options); @@ -1667,7 +1794,10 @@ public void TestUpdateEntityWithInvalidMappings(string mappings) linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: mappings.Split(','), - config: _testRuntimeConfig); + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null + ); string runtimeConfig = GetInitialConfigString() + "," + @" ""entities"": { @@ -1719,7 +1849,9 @@ public void TestUpdateEntityWithInvalidPermissionAndFields(IEnumerable P linkingTargetFields: new string[] { }, relationshipFields: new string[] { }, map: null, - config: _testRuntimeConfig + config: _testRuntimeConfig, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null ); string runtimeConfig = GetConfigWithMappings(); diff --git a/src/Cli/test/UtilsTests.cs b/src/Cli/test/UtilsTests.cs index a65c8c2af4..b882e73de9 100644 --- a/src/Cli/test/UtilsTests.cs +++ b/src/Cli/test/UtilsTests.cs @@ -17,80 +17,47 @@ public void SetupLoggerForCLI() } /// - /// Test to check if it successfully creates the rest object - /// which can be either a boolean value - /// or a RestEntitySettings object + /// Test to validate the REST Path constructed from the input entered using + /// --rest option /// - [TestMethod] - public void TestGetRestDetails() + /// REST Route input from the --rest option + /// Expected REST path to be constructed + [DataTestMethod] + [DataRow(null, null, DisplayName = "No Rest Path definition")] + [DataRow("true", true, DisplayName = "REST enabled for the entity")] + [DataRow("false", false, DisplayName = "REST disabled for the entity")] + [DataRow("customPath", "/customPath", DisplayName = "Custom REST path defined for the entity")] + public void TestContructRestPathDetails(string? restRoute, object? expectedRestPath) { - // When the rest is a boolean object - object? restDetails = GetRestDetails("true"); - Assert.IsNotNull(restDetails); - Assert.IsInstanceOfType(restDetails, typeof(bool)); - Assert.IsTrue((bool)restDetails); - - restDetails = GetRestDetails("True"); - Assert.IsNotNull(restDetails); - Assert.IsInstanceOfType(restDetails, typeof(bool)); - Assert.IsTrue((bool)restDetails); - - restDetails = GetRestDetails("false"); - Assert.IsNotNull(restDetails); - Assert.IsInstanceOfType(restDetails, typeof(bool)); - Assert.IsFalse((bool)restDetails); - - restDetails = GetRestDetails("False"); - Assert.IsNotNull(restDetails); - Assert.IsInstanceOfType(restDetails, typeof(bool)); - Assert.IsFalse((bool)restDetails); - - // When rest is non-boolean string - restDetails = GetRestDetails("book"); - Assert.AreEqual(new RestEntitySettings(Path: "/book"), restDetails); + object? actualRestPathDetails = ConstructRestPathDetails(restRoute); + Assert.AreEqual(expectedRestPath, actualRestPathDetails); } /// - /// Test to check if it successfully creates the graphql object which can be either a boolean value - /// or a GraphQLEntitySettings object containing graphql type {singular, plural} based on the input + /// Test to validate the GraphQL Type constructed from the input entered using + /// --graphql option /// - [TestMethod] - public void TestGetGraphQLDetails() + /// GraphQL Type input from --graphql option + /// Expected GraphQL Type to be constructed + [DataTestMethod] + [DataRow(null, null, false, DisplayName = "No GraphQL Type definition")] + [DataRow("true", true, false, DisplayName = "GraphQL enabled for the entity")] + [DataRow("false", false, false, DisplayName = "GraphQL disabled for the entity")] + [DataRow("book", null, true, DisplayName = "Custom GraphQL type - Singular value defined")] + [DataRow("book:books", null, true, DisplayName = "Custom GraphQL type - Singular and Plural values defined")] + public void TestConstructGraphQLTypeDetails(string? graphQLType, object? expectedGraphQLType, bool isSingularPluralType) { - object? graphQlDetails = GetGraphQLDetails("true"); - Assert.IsNotNull(graphQlDetails); - Assert.IsInstanceOfType(graphQlDetails, typeof(bool)); - Assert.IsTrue((bool)graphQlDetails); - - graphQlDetails = GetGraphQLDetails("True"); - Assert.IsNotNull(graphQlDetails); - Assert.IsInstanceOfType(graphQlDetails, typeof(bool)); - Assert.IsTrue((bool)graphQlDetails); - - graphQlDetails = GetGraphQLDetails("false"); - Assert.IsNotNull(graphQlDetails); - Assert.IsInstanceOfType(graphQlDetails, typeof(bool)); - Assert.IsFalse((bool)graphQlDetails); - - graphQlDetails = GetGraphQLDetails("False"); - Assert.IsNotNull(graphQlDetails); - Assert.IsInstanceOfType(graphQlDetails, typeof(bool)); - Assert.IsFalse((bool)graphQlDetails); - - //when graphql is null - Assert.IsNull(GetGraphQLDetails(null)); - - // When graphql is non-boolean string - graphQlDetails = GetGraphQLDetails("book"); - Assert.AreEqual(new GraphQLEntitySettings(Type: new SingularPlural(Singular: "book", Plural: "books")), graphQlDetails); - - // When graphql is a pair of string for custom singular, plural string. - graphQlDetails = GetGraphQLDetails("book:plural_books"); - Assert.AreEqual(new GraphQLEntitySettings(Type: new SingularPlural(Singular: "book", Plural: "plural_books")), graphQlDetails); + object? actualGraphQLType = ConstructGraphQLTypeDetails(graphQLType); + if (!isSingularPluralType) + { + Assert.AreEqual(expectedGraphQLType, actualGraphQLType); + } + else + { + SingularPlural expectedType = new(Singular: "book", Plural: "books"); + Assert.AreEqual(expectedType, actualGraphQLType); + } - // Invalid graphql string - graphQlDetails = GetGraphQLDetails("book:plural_books:ads"); - Assert.IsNull(graphQlDetails); } /// @@ -138,11 +105,14 @@ public void TestTryParseSourceParameterDictionary() } /// - /// Test to verify that stored-procedures contain only 1 CRUD operation. + /// Validates permissions operations are valid for the provided source type. /// + /// CRUD + Execute + * + /// Table, StoredProcedure, View + /// True/False [DataTestMethod] [DataRow(new string[] { "*" }, SourceType.StoredProcedure, false, DisplayName = "FAIL: Stored-Procedure with wildcard CRUD operation.")] - [DataRow(new string[] { "create" }, SourceType.StoredProcedure, true, DisplayName = "PASS: Stored-Procedure with 1 CRUD operation.")] + [DataRow(new string[] { "execute" }, SourceType.StoredProcedure, true, DisplayName = "PASS: Stored-Procedure with execute operation only.")] [DataRow(new string[] { "create", "read" }, SourceType.StoredProcedure, false, DisplayName = "FAIL: Stored-Procedure with more than 1 CRUD operation.")] [DataRow(new string[] { "*" }, SourceType.Table, true, DisplayName = "PASS: Table with wildcard CRUD operation.")] [DataRow(new string[] { "create" }, SourceType.Table, true, DisplayName = "PASS: Table with 1 CRUD operation.")] diff --git a/src/Config/Action.cs b/src/Config/Action.cs index 7fcf702e03..63c26a1630 100644 --- a/src/Config/Action.cs +++ b/src/Config/Action.cs @@ -21,6 +21,7 @@ public record PermissionOperation( { // Set of allowed operations for a request. public static readonly HashSet ValidPermissionOperations = new() { Operation.Create, Operation.Read, Operation.Update, Operation.Delete }; + public static readonly HashSet ValidStoredProcedurePermissionOperations = new() { Operation.Execute }; } /// @@ -59,8 +60,10 @@ public override void Write(Utf8JsonWriter writer, Operation value, JsonSerialize public enum Operation { None, + // * All, + // Common Operations Delete, Read, @@ -71,7 +74,10 @@ public enum Operation Insert, Update, UpdateGraphQL, // Additional - UpsertIncremental, UpdateIncremental + UpsertIncremental, UpdateIncremental, + + // Only valid operation for stored procedures + Execute } /// @@ -114,4 +120,19 @@ public Policy(string? request, string? database) [property: JsonPropertyName("database")] public string? Database { get; set; } } + + public enum RestMethod + { + Get, + Post, + Put, + Patch, + Delete + }; + + public enum GraphQLOperation + { + Query, + Mutation + }; } diff --git a/src/Config/Entity.cs b/src/Config/Entity.cs index 4f7748ff04..70f1b003aa 100644 --- a/src/Config/Entity.cs +++ b/src/Config/Entity.cs @@ -25,7 +25,6 @@ namespace Azure.DataApiBuilder.Config public record Entity( [property: JsonPropertyName("source")] object Source, - [property: JsonPropertyName("rest")] object? Rest, object? GraphQL, [property: JsonPropertyName("permissions")] @@ -49,6 +48,9 @@ public record Entity( [JsonIgnore] public string[]? KeyFields { get; private set; } + [property: JsonPropertyName("rest")] + public object? Rest { get; set; } = Rest; + [property: JsonPropertyName("graphql")] public object? GraphQL { get; set; } = GraphQL; @@ -76,12 +78,12 @@ DatabaseObjectSource objectSource } /// - /// Processes per entity GraphQL Naming Settings - /// Top Level: true | false - /// Alternatives: string, SingularPlural object - /// returns true on successfull processing - /// else false. + /// Processes per entity GraphQL runtime configuration JSON: + /// (bool) GraphQL enabled for entity true | false + /// (JSON Object) Alternative Naming: string, SingularPlural object + /// (JSON Object) Explicit Stored Procedure operation type "query" or "mutation" /// + /// True when processed successfully, otherwise false. public bool TryProcessGraphQLNamingConfig() { if (GraphQL is null) @@ -97,25 +99,87 @@ public bool TryProcessGraphQLNamingConfig() } else if (configElement.ValueKind is JsonValueKind.Object) { - JsonElement nameTypeSettings = configElement.GetProperty("type"); - object nameConfiguration; + // Hydrate the ObjectType field with metadata from database source. + TryPopulateSourceFields(); - if (nameTypeSettings.ValueKind is JsonValueKind.String) + object? typeConfiguration = null; + if (configElement.TryGetProperty(propertyName: "type", out JsonElement nameTypeSettings)) { - nameConfiguration = JsonSerializer.Deserialize(nameTypeSettings)!; + if (nameTypeSettings.ValueKind is JsonValueKind.True || nameTypeSettings.ValueKind is JsonValueKind.False) + { + typeConfiguration = JsonSerializer.Deserialize(nameTypeSettings); + } + else if (nameTypeSettings.ValueKind is JsonValueKind.String) + { + typeConfiguration = JsonSerializer.Deserialize(nameTypeSettings)!; + } + else if (nameTypeSettings.ValueKind is JsonValueKind.Object) + { + typeConfiguration = JsonSerializer.Deserialize(nameTypeSettings)!; + } + else + { + // Not Supported Type + return false; + } } - else if (nameTypeSettings.ValueKind is JsonValueKind.Object) + + // Only stored procedure configuration can override the GraphQL operation type. + // When the entity is a stored procedure, GraphQL metadata will either be: + // - GraphQLStoredProcedureEntityOperationSettings when only operation is configured. + // - GraphQLStoredProcedureEntityVerboseSettings when both type and operation are configured. + // This verbosity is necessary to ensure the operation key/value pair is not persisted in the runtime config + // for non stored procedure entity types. + if (ObjectType is SourceType.StoredProcedure) { - nameConfiguration = JsonSerializer.Deserialize(nameTypeSettings)!; + GraphQLOperation? graphQLOperation; + if (configElement.TryGetProperty(propertyName: "operation", out JsonElement operation) + && operation.ValueKind is JsonValueKind.String) + { + try + { + string? deserializedOperation = JsonSerializer.Deserialize(operation); + if (string.IsNullOrWhiteSpace(deserializedOperation)) + { + graphQLOperation = GraphQLOperation.Mutation; + } + else if (Enum.TryParse(deserializedOperation, ignoreCase: true, out GraphQLOperation resolvedOperation)) + { + graphQLOperation = resolvedOperation; + } + else + { + throw new JsonException(message: $"Unsupported GraphQL operation type: {operation}"); + } + } + catch (Exception error) when ( + error is JsonException || + error is ArgumentNullException || + error is NotSupportedException || + error is InvalidOperationException || + error is ArgumentException) + { + throw new JsonException(message: $"Unsupported GraphQL operation type: {operation}", innerException: error); + } + } + else + { + graphQLOperation = GraphQLOperation.Mutation; + } + + if (typeConfiguration is null) + { + GraphQL = new GraphQLStoredProcedureEntityOperationSettings(GraphQLOperation: graphQLOperation.ToString()); + } + else + { + GraphQL = new GraphQLStoredProcedureEntityVerboseSettings(Type: typeConfiguration, GraphQLOperation: graphQLOperation.ToString()); + } } else { - // Not Supported Type - return false; + GraphQL = new GraphQLEntitySettings(Type: typeConfiguration); } - - GraphQLEntitySettings graphQLEntitySettings = new(Type: nameConfiguration); - GraphQL = graphQLEntitySettings; } } else @@ -127,6 +191,90 @@ public bool TryProcessGraphQLNamingConfig() return true; } + /// + /// Returns the GraphQL operation that is configured for the stored procedure as a string. + /// + /// Name of the graphQL operation as a string or null if no operation type is resolved. + public string? GetGraphQLOperationAsString() + { + return FetchGraphQLOperation().ToString(); + } + + /// + /// Fetches the name of the graphQL operation configured for the stored procedure as an enum. + /// + /// Name of the graphQL operation as an enum or null if parsing of the enum fails. + public GraphQLOperation? FetchGraphQLOperation() + { + if (GraphQL is true || GraphQL is null || GraphQL is GraphQLEntitySettings _) + { + return GraphQLOperation.Mutation; + } + else if (GraphQL is GraphQLStoredProcedureEntityOperationSettings operationSettings) + { + return Enum.TryParse(operationSettings.GraphQLOperation, ignoreCase: true, out GraphQLOperation operation) ? operation : null; + } + else if (GraphQL is GraphQLStoredProcedureEntityVerboseSettings verboseSettings) + { + return Enum.TryParse(verboseSettings.GraphQLOperation, ignoreCase: true, out GraphQLOperation operation) ? operation : null; + } + + return null; + } + + /// + /// Gets an entity's GraphQL Type metadata by deserializing the JSON runtime configuration. + /// + /// GraphQL Type configuration for the entity. + /// Raised when unsupported GraphQL configuration is present on the property "type" + public object? GetGraphQLEnabledOrPath() + { + if (GraphQL is null) + { + return null; + } + + JsonElement graphQLConfigElement = (JsonElement)GraphQL; + if (graphQLConfigElement.ValueKind is JsonValueKind.True || graphQLConfigElement.ValueKind is JsonValueKind.False) + { + return JsonSerializer.Deserialize(graphQLConfigElement); + } + else if (graphQLConfigElement.ValueKind is JsonValueKind.String) + { + return JsonSerializer.Deserialize(graphQLConfigElement); + } + else if (graphQLConfigElement.ValueKind is JsonValueKind.Object) + { + if (graphQLConfigElement.TryGetProperty("type", out JsonElement graphQLTypeElement)) + { + if (graphQLTypeElement.ValueKind is JsonValueKind.True || graphQLTypeElement.ValueKind is JsonValueKind.False) + { + return JsonSerializer.Deserialize(graphQLTypeElement); + } + else if (graphQLTypeElement.ValueKind is JsonValueKind.String) + { + return JsonSerializer.Deserialize(graphQLTypeElement); + } + else if (graphQLTypeElement.ValueKind is JsonValueKind.Object) + { + return JsonSerializer.Deserialize(graphQLTypeElement); + } + else + { + throw new JsonException("Unsupported GraphQL Type"); + } + } + else + { + return null; + } + } + else + { + throw new JsonException("Unsupported GraphQL Type"); + } + } + /// /// After the Entity has been deserialized, populate the source-related fields /// Deserialize into DatabaseObjectSource to parse fields if source is an object @@ -174,6 +322,88 @@ public void TryPopulateSourceFields() throw new JsonException(message: $"Source not one of string or object"); } } + + /// + /// Gets the REST HTTP methods configured for the stored procedure + /// + /// An array of HTTP methods configured + public RestMethod[]? GetRestMethodsConfiguredForStoredProcedure() + { + if (Rest is not null && ((JsonElement)Rest).ValueKind is JsonValueKind.Object) + { + if (((JsonElement)Rest).TryGetProperty("path", out JsonElement _)) + { + RestStoredProcedureEntitySettings? restSpSettings = JsonSerializer.Deserialize((JsonElement)Rest, RuntimeConfig.SerializerOptions); + if (restSpSettings is not null) + { + return restSpSettings.RestMethods; + } + + } + else + { + RestStoredProcedureEntityVerboseSettings? restSpSettings = JsonSerializer.Deserialize((JsonElement)Rest, RuntimeConfig.SerializerOptions); + if (restSpSettings is not null) + { + return restSpSettings.RestMethods; + } + } + } + + return new RestMethod[] { RestMethod.Post }; + } + + /// + /// Gets the REST API Path Settings for the entity. + /// When REST is enabled or disabled without a custom path definition, this + /// returns a boolean true/false respectively. + /// When a custom path is configured, this returns the custom path definition. + /// + /// + /// + public object? GetRestEnabledOrPathSettings() + { + if (Rest is null) + { + return null; + } + + JsonElement RestConfigElement = (JsonElement)Rest; + if (RestConfigElement.ValueKind is JsonValueKind.True || RestConfigElement.ValueKind is JsonValueKind.True) + { + return JsonSerializer.Deserialize(RestConfigElement); + } + else if (RestConfigElement.ValueKind is JsonValueKind.String) + { + return JsonSerializer.Deserialize(RestConfigElement); + } + else if (RestConfigElement.ValueKind is JsonValueKind.Object) + { + if (RestConfigElement.TryGetProperty("path", out JsonElement restPathElement)) + { + if (restPathElement.ValueKind is JsonValueKind.True || restPathElement.ValueKind is JsonValueKind.False) + { + return JsonSerializer.Deserialize(restPathElement); + } + else if (restPathElement.ValueKind is JsonValueKind.String) + { + return JsonSerializer.Deserialize(restPathElement); + } + else + { + throw new JsonException("Unsupported Rest Path Type"); + } + } + else + { + return null; + } + } + else + { + throw new JsonException("Unsupported Rest Type"); + } + } } /// @@ -296,16 +526,53 @@ public enum SourceType /// at which the REST endpoint for this entity is exposed /// instead of using the entity-name. Can be a string type. /// - public record RestEntitySettings(object Path); + public record RestEntitySettings(object? Path); + + /// + /// Describes the REST settings specific to an entity backed by a stored procedure. + /// + /// Defines the HTTP actions that are supported for stored procedures. + public record RestStoredProcedureEntitySettings([property: JsonPropertyName("methods")] RestMethod[]? RestMethods = null); + + /// + /// Describes the verbose REST settings specific to an entity backed by a stored procedure. + /// Both path overrides and methods overrides can be defined. + /// + /// Instructs the runtime to use this as the path + /// at which the REST endpoint for this entity is exposed + /// instead of using the entity-name. Can be a string type. + /// + /// Defines the HTTP actions that are supported for stored procedures. + public record RestStoredProcedureEntityVerboseSettings(object? Path, + [property: JsonPropertyName("methods")] RestMethod[]? RestMethods = null); /// /// Describes the GraphQL settings specific to an entity. /// - /// Defines the name of the GraphQL type - /// that will be used for this entity.Can be a string or Singular-Plural type. + /// Defines the name of the GraphQL type. + /// Can be a string or Singular-Plural type. /// If string, a default plural route will be added as per the rules at - /// - public record GraphQLEntitySettings([property: JsonPropertyName("type")] object? Type); + /// + /// + public record GraphQLEntitySettings([property: JsonPropertyName("type")] object? Type = null); + + /// + /// Describes the GraphQL settings applicable to an entity which is backed by a stored procedure. + /// The GraphQL Operation denotes the field type generated for the stored procedure: mutation or query. + /// + /// Defines the graphQL operation (mutation/query) that is supported for stored procedures + /// that will be used for this entity." + public record GraphQLStoredProcedureEntityOperationSettings([property: JsonPropertyName("operation")] string? GraphQLOperation = null); + + /// + /// Describes the GraphQL settings applicable to an entity which is backed by a stored procedure. + /// The GraphQL Operation denotes the field type generated for the stored procedure: mutation or query. + /// + /// Defines the name of the GraphQL type + /// Defines the graphQL operation (mutation/query) that is supported for stored procedures + /// that will be used for this entity." + public record GraphQLStoredProcedureEntityVerboseSettings([property: JsonPropertyName("type")] object? Type = null, + [property: JsonPropertyName("operation")] string? GraphQLOperation = null); /// /// Defines a name or route as singular (required) or diff --git a/src/Config/PermissionSetting.cs b/src/Config/PermissionSetting.cs index eeda1e4dd9..646c6f3262 100644 --- a/src/Config/PermissionSetting.cs +++ b/src/Config/PermissionSetting.cs @@ -3,16 +3,19 @@ namespace Azure.DataApiBuilder.Config { /// - /// Defines who (in terms of roles) can access the entity and using which operations. + /// Defines which operations (Creat, Read, Update, Delete, Execute) are permitted for a given role. /// - /// Name of the role to which defined permission applies. - /// Either a mixed-type array of a string or an object - /// that details what operations are allowed to related roles. - /// In a simple case, the array members are one of the following: - /// create, read, update, delete, *. - /// The Operation.All (wildcard *) can be used to mean all the operations. public class PermissionSetting { + /// + /// Creates a single permission mapping one role to its supported operations. + /// + /// Name of the role to which defined permission applies. + /// Either a mixed-type array of a string or an object + /// that details what operations are allowed to related roles. + /// In a simple case, the array members are one of the following: + /// create, read, update, delete, *. + /// The Operation.All (wildcard *) can be used to represent all options supported for that entity's source type. public PermissionSetting(string role, object[] operations) { Role = role; diff --git a/src/Service.GraphQLBuilder/GraphQLNaming.cs b/src/Service.GraphQLBuilder/GraphQLNaming.cs index 6068a6d7e0..e1299d3698 100644 --- a/src/Service.GraphQLBuilder/GraphQLNaming.cs +++ b/src/Service.GraphQLBuilder/GraphQLNaming.cs @@ -129,18 +129,44 @@ public static bool TryGetSingularPluralConfiguration(Entity configEntity, [NotNu } } } + else if (configEntity.GraphQL is not null && configEntity.GraphQL is GraphQLStoredProcedureEntityVerboseSettings graphQLStoredProcedureEntityVerboseSettings) + { + if (graphQLStoredProcedureEntityVerboseSettings is not null && graphQLStoredProcedureEntityVerboseSettings.Type is SingularPlural singularPlural) + { + if (singularPlural is not null) + { + singularPluralConfig = singularPlural; + return true; + } + } + } singularPluralConfig = null; return false; } + /// + /// Gets the GraphQL type name from an entity's GraphQL configuration that exists as + /// GraphQLEntitySettings or GraphQLStoredProcedureEntityVerboseSettings. + /// + /// + /// Resolved GraphQL name + /// True if an entity's GraphQL settings are populated and a GraphQL name was resolved. Otherwise, false. public static bool TryGetConfiguredGraphQLName(Entity configEntity, [NotNullWhen(true)] out string? graphQLName) { if (configEntity.GraphQL is not null && configEntity.GraphQL is GraphQLEntitySettings graphQLEntitySettings) { - if (graphQLEntitySettings is not null && graphQLEntitySettings.Type is string typeEntityName) + if (graphQLEntitySettings is not null && graphQLEntitySettings.Type is string graphQLTypeName) { - graphQLName = typeEntityName; + graphQLName = graphQLTypeName; + return true; + } + } + else if (configEntity.GraphQL is not null && configEntity.GraphQL is GraphQLStoredProcedureEntityVerboseSettings graphQLSpEntityVerboseSettings) + { + if (graphQLSpEntityVerboseSettings is not null && graphQLSpEntityVerboseSettings.Type is string graphQLTypeName) + { + graphQLName = graphQLTypeName; return true; } } @@ -225,13 +251,16 @@ public static string GenerateListQueryName(string entityName, Entity entity) } /// - /// Generates the query name of a stored procedure exposed for GraphQL. + /// Generates the (query/mutation) field name to be included in the generated GraphQL schema for a stored procedure. + /// The name will be prefixed with 'execute' + /// e.g. executeEntityName /// - /// Name of the entity - /// Name of the list query - public static string GenerateStoredProcedureQueryName(string entityName, Entity entity) + /// Name of the entity. + /// Name to be used for the stored procedure GraphQL field. + public static string GenerateStoredProcedureGraphQLFieldName(string entityName, Entity entity) { - return FormatNameForField(GetDefinedSingularName(entityName, entity)); + string preformattedField = $"execute{GetDefinedSingularName(entityName, entity)}"; + return FormatNameForField(preformattedField); } } } diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs index 0c1e341067..76278da88f 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -48,7 +48,7 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema( return new( location: null, - new NameNode(GenerateStoredProcedureQueryName(name.Value, entity)), + new NameNode(GenerateStoredProcedureGraphQLFieldName(name.Value, entity)), new StringValueNode($"Execute Stored-Procedure {name.Value} and get results from the database"), inputValues, new NonNullTypeNode(new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(name)))), diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index d6358e6e53..7176947ab4 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -43,24 +43,30 @@ public static DocumentNode Build( // unlike table/views where we create one for each CUD operation. if (entities[dbEntityName].ObjectType is SourceType.StoredProcedure) { - // If the role has actions other than READ, a schema for mutation will be generated. - Operation storedProcedureOperation = GetOperationTypeForStoredProcedure(dbEntityName, entityPermissionsMap); - if (storedProcedureOperation is not Operation.Read) + // check graphql sp config + string entityName = ObjectTypeToEntityName(objectTypeDefinitionNode); + Entity entity = entities[entityName]; + bool isSPDefinedAsMutation = entity.FetchGraphQLOperation() is GraphQLOperation.Mutation; + + if (isSPDefinedAsMutation) { - AddMutationsForStoredProcedure(dbEntityName, storedProcedureOperation, entityPermissionsMap, name, entities, mutationFields); + AddMutationsForStoredProcedure(dbEntityName, entityPermissionsMap, name, entities, mutationFields); } - - continue; } - - AddMutations(dbEntityName, operation: Operation.Create, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); - AddMutations(dbEntityName, operation: Operation.Update, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); - AddMutations(dbEntityName, operation: Operation.Delete, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); + else + { + AddMutations(dbEntityName, operation: Operation.Create, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); + AddMutations(dbEntityName, operation: Operation.Update, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); + AddMutations(dbEntityName, operation: Operation.Delete, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); + } } } List definitionNodes = new(); - // Only add mutation type if we have fields authorized for mutation operations + + // Only add mutation type if we have fields authorized for mutation operations. + // Per GraphQL Specification (Oct 2021) https://spec.graphql.org/October2021/#sec-Root-Operation-Types + // "The mutation root operation type is optional; if it is not provided, the service does not support mutations." if (mutationFields.Count() > 0) { definitionNodes.Add(new ObjectTypeDefinitionNode(null, new NameNode("Mutation"), null, new List(), new List(), mutationFields)); @@ -70,21 +76,6 @@ public static DocumentNode Build( return new(definitionNodes); } - /// - /// Tries to fetch the Operation Type for Stored Procedure. - /// Stored Procedure currently supports exactly 1 CRUD operation at a time. - /// This check is done during initialization as part of config validation. - /// - private static Operation GetOperationTypeForStoredProcedure( - string dbEntityName, - Dictionary? entityPermissionsMap) - { - List operations = entityPermissionsMap![dbEntityName].OperationToRolesMap.Keys.ToList(); - - // Stored Procedure will have only CRUD action. - return operations.First(); - } - /// /// Helper function to create mutation definitions. /// @@ -133,29 +124,35 @@ List mutationFields } /// - /// Helper method to add the new StoredProcedure in the mutation fields - /// of GraphQL Schema + /// Uses the provided input arguments to add a stored procedure to the GraphQL schema as a mutation field when + /// at least one role with permission to execute is defined in the stored procedure's entity definition within the runtime config. /// private static void AddMutationsForStoredProcedure( string dbEntityName, - Operation operation, Dictionary? entityPermissionsMap, NameNode name, IDictionary entities, List mutationFields ) { - IEnumerable rolesAllowedForMutation = IAuthorizationResolver.GetRolesForOperation(dbEntityName, operation: operation, entityPermissionsMap); + IEnumerable rolesAllowedForMutation = IAuthorizationResolver.GetRolesForOperation(dbEntityName, operation: Operation.Execute, entityPermissionsMap); if (rolesAllowedForMutation.Count() > 0) { mutationFields.Add(GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(name, entities[dbEntityName], rolesAllowedForMutation)); } } + /// + /// Evaluates the provided mutation name to determine the operation type. + /// e.g. createEntity is resolved to Operation.Create + /// + /// Mutation name + /// Operation public static Operation DetermineMutationOperationTypeBasedOnInputType(string inputTypeName) { return inputTypeName switch { + string s when s.StartsWith(Operation.Execute.ToString(), StringComparison.OrdinalIgnoreCase) => Operation.Execute, string s when s.StartsWith(Operation.Create.ToString(), StringComparison.OrdinalIgnoreCase) => Operation.Create, string s when s.StartsWith(Operation.Update.ToString(), StringComparison.OrdinalIgnoreCase) => Operation.UpdateGraphQL, _ => Operation.Delete diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index 6c92959598..beda082454 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -49,28 +49,33 @@ public static DocumentNode Build( string entityName = ObjectTypeToEntityName(objectTypeDefinitionNode); Entity entity = entities[entityName]; - ObjectTypeDefinitionNode? paginationReturnType = GenerateReturnType(name); + if (entity.ObjectType is SourceType.StoredProcedure) + { + // Check runtime configuration of the stored procedure entity to check that the GraphQL operation type was overridden to 'query' from the default 'mutation.' + bool isSPDefinedAsQuery = entity.FetchGraphQLOperation() is GraphQLOperation.Query; - IEnumerable rolesAllowedForRead = IAuthorizationResolver.GetRolesForOperation(entityName, operation: Operation.Read, entityPermissionsMap); + IEnumerable rolesAllowedForExecute = IAuthorizationResolver.GetRolesForOperation(entityName, operation: Operation.Execute, entityPermissionsMap); - if (rolesAllowedForRead.Count() > 0) - { - if (entity.ObjectType is SourceType.StoredProcedure) + if (isSPDefinedAsQuery && rolesAllowedForExecute.Any()) { - // This assignment prevents the generation of pagination fields in the schema for stored procedures - paginationReturnType = null; - queryFields.Add(GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(name, entity, rolesAllowedForRead)); + queryFields.Add(GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(name, entity, rolesAllowedForExecute)); } - else + } + else + { + IEnumerable rolesAllowedForRead = IAuthorizationResolver.GetRolesForOperation(entityName, operation: Operation.Read, entityPermissionsMap); + ObjectTypeDefinitionNode paginationReturnType = GenerateReturnType(name); + + if (rolesAllowedForRead.Count() > 0) { queryFields.Add(GenerateGetAllQuery(objectTypeDefinitionNode, name, paginationReturnType, inputTypes, entity, rolesAllowedForRead)); queryFields.Add(GenerateByPKQuery(objectTypeDefinitionNode, name, databaseType, entity, rolesAllowedForRead)); } - } - if (paginationReturnType is not null) - { - returnTypes.Add(paginationReturnType); + if (paginationReturnType is not null) + { + returnTypes.Add(paginationReturnType); + } } } } diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs index fa708cd47b..b537678894 100644 --- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs +++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs @@ -913,7 +913,7 @@ public void AreColumnsAllowedForOperationWithRoleWithDifferentCasing( ); AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig); - List operations = AuthorizationResolver.GetAllOperations(operation).ToList(); + List operations = AuthorizationResolver.GetAllOperationsForObjectType(operation, SourceType.Table).ToList(); foreach (Config.Operation testOperation in operations) { diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index c89dcec711..72b0b31aca 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -572,29 +572,83 @@ private static void ConfigFileDeserializationValidationHelper(string jsonString) foreach (Entity entity in runtimeConfig.Entities.Values) { - Assert.IsTrue(((JsonElement)entity.Source).ValueKind == JsonValueKind.String - || ((JsonElement)entity.Source).ValueKind == JsonValueKind.Object); + Assert.IsTrue(((JsonElement)entity.Source).ValueKind is JsonValueKind.String + || ((JsonElement)entity.Source).ValueKind is JsonValueKind.Object); - Assert.IsTrue(entity.Rest == null - || ((JsonElement)entity.Rest).ValueKind == JsonValueKind.True - || ((JsonElement)entity.Rest).ValueKind == JsonValueKind.False - || ((JsonElement)entity.Rest).ValueKind == JsonValueKind.Object); + Assert.IsTrue(entity.Rest is null + || ((JsonElement)entity.Rest).ValueKind is JsonValueKind.True + || ((JsonElement)entity.Rest).ValueKind is JsonValueKind.False + || ((JsonElement)entity.Rest).ValueKind is JsonValueKind.Object); if (entity.Rest != null - && ((JsonElement)entity.Rest).ValueKind == JsonValueKind.Object) + && ((JsonElement)entity.Rest).ValueKind is JsonValueKind.Object) { - RestEntitySettings rest = - ((JsonElement)entity.Rest).Deserialize(RuntimeConfig.SerializerOptions); - Assert.IsTrue(((JsonElement)rest.Path).ValueKind == JsonValueKind.String); + JsonElement restConfigElement = (JsonElement)entity.Rest; + if (!restConfigElement.TryGetProperty("methods", out JsonElement _)) + { + RestEntitySettings rest = JsonSerializer.Deserialize(restConfigElement, RuntimeConfig.SerializerOptions); + Assert.IsTrue(((JsonElement)rest.Path).ValueKind is JsonValueKind.String + || ((JsonElement)rest.Path).ValueKind is JsonValueKind.True + || ((JsonElement)rest.Path).ValueKind is JsonValueKind.False); + } + else + { + if (!restConfigElement.TryGetProperty("path", out JsonElement _)) + { + RestStoredProcedureEntitySettings rest = JsonSerializer.Deserialize(restConfigElement, RuntimeConfig.SerializerOptions); + Assert.AreEqual(typeof(RestMethod[]), rest.RestMethods.GetType()); + } + else + { + RestStoredProcedureEntityVerboseSettings rest = JsonSerializer.Deserialize(restConfigElement, RuntimeConfig.SerializerOptions); + Assert.AreEqual(typeof(RestMethod[]), rest.RestMethods.GetType()); + Assert.IsTrue((((JsonElement)rest.Path).ValueKind is JsonValueKind.String) + || (((JsonElement)rest.Path).ValueKind is JsonValueKind.True) + || (((JsonElement)rest.Path).ValueKind is JsonValueKind.False)); + } + + } + + } + + Assert.IsTrue(entity.GraphQL is null + || entity.GraphQL.GetType() == typeof(bool) + || entity.GraphQL.GetType() == typeof(GraphQLEntitySettings) + || entity.GraphQL.GetType() == typeof(GraphQLStoredProcedureEntityOperationSettings) + || entity.GraphQL.GetType() == typeof(GraphQLStoredProcedureEntityVerboseSettings)); + + if (entity.GraphQL is not null) + { + if (entity.GraphQL.GetType() == typeof(GraphQLEntitySettings)) + { + GraphQLEntitySettings graphQL = (GraphQLEntitySettings)entity.GraphQL; + Assert.IsTrue(graphQL.Type.GetType() == typeof(string) + || graphQL.Type.GetType() == typeof(SingularPlural)); + } + else if (entity.GraphQL.GetType() == typeof(GraphQLStoredProcedureEntityOperationSettings)) + { + GraphQLStoredProcedureEntityOperationSettings graphQL = (GraphQLStoredProcedureEntityOperationSettings)entity.GraphQL; + Assert.AreEqual(typeof(string), graphQL.GraphQLOperation.GetType()); + } + else if (entity.GraphQL.GetType() == typeof(GraphQLStoredProcedureEntityVerboseSettings)) + { + GraphQLStoredProcedureEntityVerboseSettings graphQL = (GraphQLStoredProcedureEntityVerboseSettings)entity.GraphQL; + Assert.AreEqual(typeof(string), graphQL.GraphQLOperation.GetType()); + Assert.IsTrue(graphQL.Type.GetType() == typeof(bool) + || graphQL.Type.GetType() == typeof(string) + || graphQL.Type.GetType() == typeof(SingularPlural)); + } } Assert.IsInstanceOfType(entity.Permissions, typeof(PermissionSetting[])); + + HashSet allowedActions = + new() { Config.Operation.All, Config.Operation.Create, Config.Operation.Read, + Config.Operation.Update, Config.Operation.Delete, Config.Operation.Execute }; foreach (PermissionSetting permission in entity.Permissions) { foreach (object operation in permission.Operations) { - HashSet allowedActions = - new() { Config.Operation.All, Config.Operation.Create, Config.Operation.Read, - Config.Operation.Update, Config.Operation.Delete }; + Assert.IsTrue(((JsonElement)operation).ValueKind == JsonValueKind.String || ((JsonElement)operation).ValueKind == JsonValueKind.Object); if (((JsonElement)operation).ValueKind == JsonValueKind.Object) diff --git a/src/Service.Tests/GraphQLBuilder/Helpers/GraphQLTestHelpers.cs b/src/Service.Tests/GraphQLBuilder/Helpers/GraphQLTestHelpers.cs index 29daf5f102..038d6a098b 100644 --- a/src/Service.Tests/GraphQLBuilder/Helpers/GraphQLTestHelpers.cs +++ b/src/Service.Tests/GraphQLBuilder/Helpers/GraphQLTestHelpers.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.GraphQLBuilder; @@ -83,6 +84,27 @@ public static Entity GenerateEmptyEntity(SourceType sourceType = SourceType.Tabl Mappings: new()); } + /// + /// Creates a stored procedure backed entity using the provided metadata. + /// + /// Desired GraphQL type name. + /// Query or Mutation + /// Collection of permission operations (CRUD+Execute) + /// Stored procedure backed entity. + public static Entity GenerateStoredProcedureEntity(string graphQLTypeName, GraphQLOperation? graphQLOperation, string[] permissionOperations) + { + Entity entity = new(Source: new DatabaseObjectSource(SourceType.StoredProcedure, Name: "foo", Parameters: null, KeyFields: null), + Rest: null, + GraphQL: JsonSerializer.SerializeToElement(new GraphQLStoredProcedureEntityVerboseSettings(Type: graphQLTypeName, GraphQLOperation: graphQLOperation.ToString())), + Permissions: new[] { new PermissionSetting(role: "anonymous", operations: permissionOperations) }, + Relationships: new(), + Mappings: new()); + + // Ensures default GraphQL operation is "mutation" for stored procedures unless defined otherwise. + entity.TryProcessGraphQLNamingConfig(); + return entity; + } + /// /// Creates an entity with a SingularPlural GraphQL type. /// diff --git a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs index cfb395bc3e..c3a9cabb06 100644 --- a/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/MutationBuilderTests.cs @@ -1002,5 +1002,74 @@ string expectedName FieldDefinitionNode deleteMutation = mutation.Fields.First(f => f.Name.Value == expectedDeleteMutationName); Assert.AreEqual(expectedDeleteMutationDescription, deleteMutation.Description.Value); } + + /// + /// Tests the GraphQL schema builder method MutationBuilder.Build()'s behavior when processing stored procedure entity configuration + /// which may explicitly define the field type(query/mutation) of the entity. + /// Only attempt to get the mutation ObjectTypeDefinitionNode when a mutation root operation type exists. + /// In this test, either a mutation is created or it is not, so only attempt to run GetMutationNode when a node is expected, + /// otherwise an exception is raised. + /// This is expected per GraphQL Specification (Oct 2021) https://spec.graphql.org/October2021/#sec-Root-Operation-Types + /// "The mutation root operation type is optional; if it is not provided, the service does not support mutations." + /// + /// Query or Mutation + /// Collection of operations denoted by their enum value, for CreateStubEntityPermissionsMap() + /// Collection of operations denoted by their string value, for GenerateStoredProcedureEntity() + /// Whether MutationBuilder will generate a mutation field for the GraphQL schema. + [DataTestMethod] + [DataRow(GraphQLOperation.Mutation, new[] { Config.Operation.Execute }, new[] { "execute" }, true, DisplayName = "Mutation field generated since all metadata is valid")] + [DataRow(null, new[] { Config.Operation.Execute }, new[] { "execute" }, true, DisplayName = "Mutation field generated since default operation is mutation.")] + [DataRow(GraphQLOperation.Mutation, new[] { Config.Operation.Read }, new[] { "read" }, false, DisplayName = "Mutation field not generated because invalid permissions were supplied")] + [DataRow(GraphQLOperation.Query, new[] { Config.Operation.Execute }, new[] { "execute" }, false, DisplayName = "Mutation field not generated because the configured operation is query.")] + public void StoredProcedureEntityAsMutationField(GraphQLOperation? graphQLOperation, Config.Operation[] operations, string[] permissionOperations, bool expectsMutationField) + { + string gql = + @" + type StoredProcedureType @model(name:""MyStoredProcedure"") { + field1: string + } + "; + + DocumentNode root = Utf8GraphQLParser.Parse(gql); + _entityPermissions = GraphQLTestHelpers.CreateStubEntityPermissionsMap( + new string[] { "MyStoredProcedure" }, + operations, + new string[] { "anonymous", "authenticated" } + ); + Entity entity = GraphQLTestHelpers.GenerateStoredProcedureEntity(graphQLTypeName: "StoredProcedureType", graphQLOperation, permissionOperations); + + DocumentNode mutationRoot = MutationBuilder.Build( + root, + DatabaseType.mssql, + new Dictionary { { "MyStoredProcedure", entity } }, + entityPermissionsMap: _entityPermissions + ); + + const string FIELDNOTFOUND_ERROR = "The expected mutation field schema was not detected."; + try + { + // Gets the specific mutation field generated for GraphQL type 'StoredProcedureType' named 'executeMyStoredProcedure' + // from the schema root field 'Mutation' + ObjectTypeDefinitionNode mutation = GetMutationNode(mutationRoot); + + // With a minimized configuration for this entity, the only field expected is the one that may be generated from this test. + Assert.IsTrue(mutation.Fields.Any(), message: FIELDNOTFOUND_ERROR); + FieldDefinitionNode field = mutation.Fields.First(f => f.Name.Value == $"executeStoredProcedureType"); + Assert.IsNotNull(field, message: FIELDNOTFOUND_ERROR); + string actualMutationType = field.Type.ToString(); + Assert.AreEqual(expected: "[StoredProcedureType!]!", actual: actualMutationType, message: $"Incorrect mutation field type: {actualMutationType}"); + } + catch (Exception ex) + { + if (expectsMutationField) + { + Assert.Fail(message: $"{FIELDNOTFOUND_ERROR} {ex.Message}"); + } + else + { + Assert.IsTrue(mutationRoot.Definitions.Count == 0, message: FIELDNOTFOUND_ERROR); + } + } + } } } diff --git a/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs index a47920551e..6abc0a0ab4 100644 --- a/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs @@ -369,6 +369,64 @@ Dictionary entityPermissionsMap Assert.AreEqual(expectedAllQueryDescription, allItemsQueryFieldNode.Description.Value); } + /// + /// Tests the GraphQL schema builder method QueryBuilder.Build()'s behavior when processing stored procedure entity configuration + /// which may explicitly define the field type(query/mutation) of the entity. + /// + /// Query or Mutation + /// CRUD + Execute -> for EntityPermissionsMap + /// CRUD + Execute -> for Entity.Permissions + /// Whether QueryBuilder will generate a query field for the GraphQL schema. + [DataTestMethod] + [DataRow(GraphQLOperation.Query, new[] { Config.Operation.Execute }, new[] { "execute" }, true, DisplayName = "Query field generated since all metadata is valid")] + [DataRow(null, new[] { Config.Operation.Execute }, new[] { "execute" }, false, DisplayName = "Query field not generated since default operation is mutation.")] + [DataRow(GraphQLOperation.Query, new[] { Config.Operation.Read }, new[] { "read" }, false, DisplayName = "Query field not generated because invalid permissions were supplied")] + [DataRow(GraphQLOperation.Mutation, new[] { Config.Operation.Execute }, new[] { "execute" }, false, DisplayName = "Query field not generated because the configured operation is mutation.")] + public void StoredProcedureEntityAsQueryField(GraphQLOperation? graphQLOperation, Config.Operation[] operations, string[] permissionOperations, bool expectsQueryField) + { + string gql = + @" + type StoredProcedureType @model(name:""MyStoredProcedure"") { + field1: string + } + "; + + DocumentNode root = Utf8GraphQLParser.Parse(gql); + _entityPermissions = GraphQLTestHelpers.CreateStubEntityPermissionsMap( + new string[] { "MyStoredProcedure" }, + operations, + new string[] { "anonymous", "authenticated" } + ); + Entity entity = GraphQLTestHelpers.GenerateStoredProcedureEntity(graphQLTypeName: "StoredProcedureType", graphQLOperation, permissionOperations); + + DocumentNode queryRoot = QueryBuilder.Build( + root, + DatabaseType.mssql, + new Dictionary { { "MyStoredProcedure", entity } }, + inputTypes: new(), + entityPermissionsMap: _entityPermissions + ); + + ObjectTypeDefinitionNode query = GetQueryNode(queryRoot); + + // With a minimized configuration for this entity, the only field expected is the one that may be generated from this test. + const string FIELDNOTFOUND_ERROR = "The expected query field definition was not detected."; + + if (expectsQueryField) + { + Assert.IsTrue(query.Fields.Any(), message: FIELDNOTFOUND_ERROR); + FieldDefinitionNode field = query.Fields.First(f => f.Name.Value == $"executeStoredProcedureType"); + Assert.IsNotNull(field, message: FIELDNOTFOUND_ERROR); + string actualQueryType = field.Type.ToString(); + Assert.AreEqual(expected: "[StoredProcedureType!]!", actual: actualQueryType, message: $"Incorrect query field type: {actualQueryType}"); + } + else + { + Assert.IsTrue(!query.Fields.Any(), message: FIELDNOTFOUND_ERROR); + } + + } + public static ObjectTypeDefinitionNode GetQueryNode(DocumentNode queryRoot) { return (ObjectTypeDefinitionNode)queryRoot.Definitions.First(d => d is ObjectTypeDefinitionNode node && node.Name.Value == "Query"); diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index 00d3e08c62..1dac672489 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -116,10 +116,10 @@ public async Task InsertMutationForConstantdefaultValue(string dbQuery) /// public async Task TestStoredProcedureMutationForInsertion(string dbQuery) { - string graphQLMutationName = "insertBook"; + string graphQLMutationName = "executeInsertBook"; string graphQLMutation = @" mutation { - insertBook(title: ""Random Book"", publisher_id: 1234 ) { + executeInsertBook(title: ""Random Book"", publisher_id: 1234 ) { result } } @@ -146,10 +146,10 @@ public async Task TestStoredProcedureMutationForInsertion(string dbQuery) /// public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyDeletion) { - string graphQLMutationName = "deleteLastInsertedBook"; + string graphQLMutationName = "executeDeleteLastInsertedBook"; string graphQLMutation = @" mutation { - deleteLastInsertedBook { + executeDeleteLastInsertedBook { result } } @@ -177,10 +177,10 @@ public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyD /// public async Task TestStoredProcedureMutationNonEmptyResponse(string dbQuery) { - string graphQLMutationName = "insertAndDisplayAllBooksUnderGivenPublisher"; + string graphQLMutationName = "executeInsertAndDisplayAllBooksUnderGivenPublisher"; string graphQLMutation = @" mutation{ - insertAndDisplayAllBooksUnderGivenPublisher(title: ""Orange Tomato"" publisher_name: ""Big Company""){ + executeInsertAndDisplayAllBooksUnderGivenPublisher(title: ""Orange Tomato"" publisher_name: ""Big Company""){ id title } @@ -201,10 +201,10 @@ public async Task TestStoredProcedureMutationNonEmptyResponse(string dbQuery) /// public async Task TestStoredProcedureMutationForUpdate(string dbQuery) { - string graphQLMutationName = "updateBookTitle"; + string graphQLMutationName = "executeUpdateBookTitle"; string graphQLMutation = @" mutation { - updateBookTitle(id: 14, title: ""Before Midnight"") { + executeUpdateBookTitle(id: 14, title: ""Before Midnight"") { id title publisher_id diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs index fc89917d80..cf7e040d1b 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs @@ -1024,9 +1024,9 @@ public virtual async Task TestQueryOnBasicView(string dbQuery) /// public async Task TestStoredProcedureQueryForGettingSingleRow(string dbQuery) { - string graphQLQueryName = "getPublisher"; - string graphQLQuery = @"{ - getPublisher(id: 1234) { + string graphQLQueryName = "executeGetPublisher"; + string graphQLQuery = @"mutation { + executeGetPublisher(id: 1234) { id name } @@ -1043,9 +1043,9 @@ public async Task TestStoredProcedureQueryForGettingSingleRow(string dbQuery) /// public async Task TestStoredProcedureQueryForGettingMultipleRows(string dbQuery) { - string graphQLQueryName = "getBooks"; + string graphQLQueryName = "executeGetBooks"; string graphQLQuery = @"{ - getBooks { + executeGetBooks { id title publisher_id @@ -1063,9 +1063,9 @@ public async Task TestStoredProcedureQueryForGettingMultipleRows(string dbQuery) /// public async Task TestStoredProcedureQueryForGettingTotalNumberOfRows(string dbQuery) { - string graphQLQueryName = "countBooks"; - string graphQLQuery = @"{ - countBooks { + string graphQLQueryName = "executeCountBooks"; + string graphQLQuery = @"mutation { + executeCountBooks { total_books } }"; @@ -1083,9 +1083,9 @@ public async Task TestStoredProcedureQueryForGettingTotalNumberOfRows(string dbQ /// public async Task TestStoredProcedureQueryWithResultsContainingNull(string dbQuery) { - string graphQLQueryName = "searchAuthorByFirstName"; - string graphQLQuery = @"{ - searchAuthorByFirstName(firstName: ""Aaron"") { + string graphQLQueryName = "executeSearchAuthorByFirstName"; + string graphQLQuery = @"mutation { + executeSearchAuthorByFirstName(firstName: ""Aaron"") { author_name first_publish_year total_books_published diff --git a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs index 1e5a87d453..91a0c545e6 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs @@ -74,7 +74,7 @@ await SetupAndRunRestApiTest( queryString: null, entityNameOrPath: _integrationProcedureDeleteOne_EntityName, sqlQuery: GetQuery(nameof(DeleteOneWithStoredProcedureTest)), - operationType: Config.Operation.Delete, + operationType: Config.Operation.Execute, requestBody: null, expectedStatusCode: HttpStatusCode.NoContent ); diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs index bdd34ee5b6..b07c154202 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs @@ -45,6 +45,8 @@ await SetupAndRunRestApiTest( primaryKeyRoute: string.Empty, queryString: string.Empty, entityNameOrPath: _integrationProcedureFindMany_EntityName, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, sqlQuery: GetQuery("FindManyStoredProcedureTest"), expectJson: false ); @@ -61,6 +63,8 @@ await SetupAndRunRestApiTest( primaryKeyRoute: string.Empty, queryString: "?id=1", entityNameOrPath: _integrationProcedureFindOne_EntityName, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, sqlQuery: GetQuery("FindOneStoredProcedureTestUsingParameter"), expectJson: false ); @@ -1020,6 +1024,8 @@ await SetupAndRunRestApiTest( queryString: string.Empty, entityNameOrPath: _integrationProcedureFindMany_EntityName, sqlQuery: string.Empty, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, exceptionExpected: true, expectedErrorMessage: "Primary key route not supported for this entity.", expectedStatusCode: HttpStatusCode.BadRequest @@ -1038,6 +1044,8 @@ await SetupAndRunRestApiTest( queryString: string.Empty, entityNameOrPath: _integrationProcedureFindOne_EntityName, sqlQuery: string.Empty, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, exceptionExpected: true, expectedErrorMessage: $"Invalid request. Missing required procedure parameters: id for entity: {_integrationProcedureFindOne_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest @@ -1057,6 +1065,8 @@ await SetupAndRunRestApiTest( queryString: "?param=value", entityNameOrPath: _integrationProcedureFindMany_EntityName, sqlQuery: string.Empty, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, exceptionExpected: true, expectedErrorMessage: $"Invalid request. Contained unexpected fields: param for entity: {_integrationProcedureFindMany_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest @@ -1068,6 +1078,8 @@ await SetupAndRunRestApiTest( queryString: "?id=1¶m=value", entityNameOrPath: _integrationProcedureFindOne_EntityName, sqlQuery: string.Empty, + operationType: Config.Operation.Execute, + restHttpVerb: Config.RestMethod.Get, exceptionExpected: true, expectedErrorMessage: $"Invalid request. Contained unexpected fields: param for entity: {_integrationProcedureFindOne_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest diff --git a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs index 6d759d018d..0c0728a965 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs @@ -282,7 +282,7 @@ await SetupAndRunRestApiTest( queryString: null, entityNameOrPath: _integrationProcedureInsertOneAndDisplay_EntityName, sqlQuery: GetQuery(queryName), - operationType: Config.Operation.Insert, + operationType: Config.Operation.Execute, requestBody: requestBody, expectedStatusCode: HttpStatusCode.Created, expectedLocationHeader: _integrationProcedureInsertOneAndDisplay_EntityName, diff --git a/src/Service.Tests/SqlTests/SqlTestBase.cs b/src/Service.Tests/SqlTests/SqlTestBase.cs index 45a3215678..8217a5b254 100644 --- a/src/Service.Tests/SqlTests/SqlTestBase.cs +++ b/src/Service.Tests/SqlTests/SqlTestBase.cs @@ -355,6 +355,7 @@ protected static async Task SetupAndRunRestApiTest( bool exceptionExpected = false, string expectedErrorMessage = "", HttpStatusCode expectedStatusCode = HttpStatusCode.OK, + RestMethod? restHttpVerb = null, string expectedSubStatusCode = "BadRequest", string expectedLocationHeader = null, string expectedAfterQueryString = "", @@ -390,7 +391,7 @@ protected static async Task SetupAndRunRestApiTest( }; // Get the httpMethod based on the operation to be executed. - HttpMethod httpMethod = SqlTestHelper.GetHttpMethodFromOperation(operationType); + HttpMethod httpMethod = SqlTestHelper.GetHttpMethodFromOperation(operationType, restHttpVerb); // Create the request to be sent to the engine. HttpRequestMessage request; diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index ff4964f4a1..caa31f5b29 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -189,9 +189,9 @@ public static async Task VerifyResultAsync( /// Helper method to get the HttpMethod based on the operation type. /// /// The operation to be executed on the entity. - /// + /// HttpMethod representing the passed in operationType. /// - public static HttpMethod GetHttpMethodFromOperation(Config.Operation operationType) + public static HttpMethod GetHttpMethodFromOperation(Config.Operation operationType, Config.RestMethod? restMethod = null) { switch (operationType) { @@ -205,6 +205,8 @@ public static HttpMethod GetHttpMethodFromOperation(Config.Operation operationTy return HttpMethod.Put; case Config.Operation.UpsertIncremental: return HttpMethod.Patch; + case Config.Operation.Execute: + return ConvertRestMethodToHttpMethod(restMethod); default: throw new DataApiBuilderException( message: "Operation not supported for the request.", @@ -213,6 +215,29 @@ public static HttpMethod GetHttpMethodFromOperation(Config.Operation operationTy } } + /// + /// Converts the provided RestMethod to the corresponding HttpMethod + /// + /// + /// HttpMethod corresponding the RestMethod provided as input. + private static HttpMethod ConvertRestMethodToHttpMethod(RestMethod? restMethod) + { + switch (restMethod) + { + case RestMethod.Get: + return HttpMethod.Get; + case RestMethod.Put: + return HttpMethod.Put; + case RestMethod.Patch: + return HttpMethod.Patch; + case RestMethod.Delete: + return HttpMethod.Delete; + case RestMethod.Post: + default: + return HttpMethod.Post; + } + } + /// /// Helper function handles the loading of the runtime config. /// diff --git a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs index bf86ec7672..354836ed0e 100644 --- a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs @@ -54,17 +54,19 @@ public void InaccessibleFieldRequestedByPolicy(string dbPolicy) /// and every role has that same single operation. /// [DataTestMethod] + [DataRow("anonymous", new object[] { "execute" }, null, null, true, false, DisplayName = "Stored-procedure with valid execute permission only")] + [DataRow("anonymous", new object[] { "execute", "read" }, null, null, false, false, DisplayName = "Invalidly define operation in excess of execute")] [DataRow("anonymous", new object[] { "create", "read" }, null, null, false, false, DisplayName = "Stored-procedure with create-read permission")] [DataRow("anonymous", new object[] { "update", "read" }, null, null, false, false, DisplayName = "Stored-procedure with update-read permission")] [DataRow("anonymous", new object[] { "delete", "read" }, null, null, false, false, DisplayName = "Stored-procedure with delete-read permission")] - [DataRow("anonymous", new object[] { "create" }, null, null, true, false, DisplayName = "Stored-procedure with only create permission")] - [DataRow("anonymous", new object[] { "read" }, null, null, true, false, DisplayName = "Stored-procedure with only read permission")] - [DataRow("anonymous", new object[] { "update" }, null, null, true, false, DisplayName = "Stored-procedure with only update permission")] - [DataRow("anonymous", new object[] { "delete" }, null, null, true, false, DisplayName = "Stored-procedure with only delete permission")] + [DataRow("anonymous", new object[] { "create" }, null, null, false, false, DisplayName = "Stored-procedure with invalid create permission")] + [DataRow("anonymous", new object[] { "read" }, null, null, false, false, DisplayName = "Stored-procedure with invalid read permission")] + [DataRow("anonymous", new object[] { "update" }, null, null, false, false, DisplayName = "Stored-procedure with invalid update permission")] + [DataRow("anonymous", new object[] { "delete" }, null, null, false, false, DisplayName = "Stored-procedure with invalid delete permission")] [DataRow("anonymous", new object[] { "update", "create" }, null, null, false, false, DisplayName = "Stored-procedure with update-create permission")] [DataRow("anonymous", new object[] { "delete", "read", "update" }, null, null, false, false, DisplayName = "Stored-procedure with delete-read-update permission")] - [DataRow("anonymous", new object[] { "create" }, "authenticated", new object[] { "create" }, true, false, DisplayName = "Stored-procedure with only create permission")] - [DataRow("anonymous", new object[] { "read" }, "authenticated", new object[] { "create" }, false, true, DisplayName = "Stored-procedure with only read permission")] + [DataRow("anonymous", new object[] { "execute" }, "authenticated", new object[] { "execute" }, true, false, DisplayName = "Stored-procedure with valid execute permission on all roles")] + [DataRow("anonymous", new object[] { "execute" }, "authenticated", new object[] { "create" }, false, true, DisplayName = "Stored-procedure with valid execute and invalid create permission")] public void InvalidCRUDForStoredProcedure( string role1, object[] operationsRole1, @@ -118,17 +120,8 @@ public void InvalidCRUDForStoredProcedure( catch (DataApiBuilderException ex) { Assert.AreEqual(false, isValid); - if (differentOperationDifferentRoleFailure) - { - Assert.AreEqual("Invalid Operations for Entity: SampleEntity. " + - $"StoredProcedure should have the same single CRUD action specified for every role.", ex.Message); - } - else - { - Assert.AreEqual("Invalid Operations for Entity: SampleEntity. " + - $"StoredProcedure can process only one CRUD (Create/Read/Update/Delete) operation.", ex.Message); - } - + Assert.AreEqual(expected: $"Invalid operation for Entity: {AuthorizationHelpers.TEST_ENTITY}. " + + $"Stored procedures can only be configured with the 'execute' operation.", actual: ex.Message); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); } @@ -902,13 +895,13 @@ public void ValidateEntitiesWithGraphQLExposedGenerateDuplicateQueries() /// generated by the entity definitions. /// This test declares entities with the following graphQL /// definitions - /// "Book" { + /// "ExecuteBook" { /// "source" :{ /// "type": "table" /// } /// "graphQL": true /// } - /// "book_by_pk" { + /// "Book_by_pk" { /// "source" :{ /// "type": "stored-procedure" /// } @@ -918,23 +911,23 @@ public void ValidateEntitiesWithGraphQLExposedGenerateDuplicateQueries() [TestMethod] public void ValidateStoredProcedureAndTableGeneratedDuplicateQueries() { - // Entity Name: Book + // Entity Name: ExecuteBook // Entity Type: table - // pk_query: book_by_pk - // List Query: books + // pk_query: executebook_by_pk + // List Query: executebooks Entity bookTable = GraphQLTestHelpers.GenerateEmptyEntity(sourceType: SourceType.Table); bookTable.GraphQL = new GraphQLEntitySettings(true); // Entity Name: book_by_pk // Entity Type: Stored Procedure - // StoredProcedure Query: book_by_pk + // StoredProcedure Query: executebook_by_pk Entity bookByPkStoredProcedure = GraphQLTestHelpers.GenerateEmptyEntity(sourceType: SourceType.StoredProcedure); bookByPkStoredProcedure.GraphQL = new GraphQLEntitySettings(true); SortedDictionary entityCollection = new(); - entityCollection.Add("Book", bookTable); - entityCollection.Add("book_by_pk", bookByPkStoredProcedure); - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "book_by_pk"); + entityCollection.Add("executeBook", bookTable); + entityCollection.Add("Book_by_pk", bookByPkStoredProcedure); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "executeBook"); } /// @@ -943,7 +936,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateQueries() /// generated by the entity definitions. /// This test declares entities with the following graphQL /// definitions - /// "Book" { + /// "ExecuteBooks" { /// "source" :{ /// "type": "table" /// } @@ -954,7 +947,7 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateQueries() /// "type": "stored-procedure" /// } /// "graphQL": { - /// "type": "createBook" + /// "type": "Books" /// } /// } /// @@ -971,13 +964,13 @@ public void ValidateStoredProcedureAndTableGeneratedDuplicateMutation() // Entity Type: Stored Procedure // StoredProcedure mutation: createBook Entity addBookStoredProcedure = GraphQLTestHelpers.GenerateEntityWithStringType( - type: "createBook", + type: "Books", sourceType: SourceType.StoredProcedure); SortedDictionary entityCollection = new(); - entityCollection.Add("Book", bookTable); + entityCollection.Add("ExecuteBooks", bookTable); entityCollection.Add("AddBook", addBookStoredProcedure); - ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "Book"); + ValidateExceptionForDuplicateQueriesDueToEntityDefinitions(entityCollection, "ExecuteBooks"); } /// diff --git a/src/Service/Authorization/AuthorizationRequirements.cs b/src/Service/Authorization/AuthorizationRequirements.cs index b4486f769b..5f4b2116f3 100644 --- a/src/Service/Authorization/AuthorizationRequirements.cs +++ b/src/Service/Authorization/AuthorizationRequirements.cs @@ -31,4 +31,13 @@ public class EntityRoleOperationPermissionsRequirement : IAuthorizationRequireme /// https://docs.microsoft.com/aspnet/core/security/authorization/policies?view=aspnetcore-6.0#requirements /// public class ColumnsPermissionsRequirement : IAuthorizationRequirement { } + + /// + /// Instructs the authorization handler to check that: + /// - The stored procedure that has been requested to execute is allowed to be accessed by the authenticated user. + /// + /// Implements IAuthorizationRequirement, which is an empty marker interface. + /// https://docs.microsoft.com/aspnet/core/security/authorization/policies?view=aspnetcore-6.0#requirements + /// + public class StoredProcedurePermissionsRequirement : IAuthorizationRequirement { } } diff --git a/src/Service/Authorization/AuthorizationResolver.cs b/src/Service/Authorization/AuthorizationResolver.cs index 5b5d53e2f0..5df7ac1381 100644 --- a/src/Service/Authorization/AuthorizationResolver.cs +++ b/src/Service/Authorization/AuthorizationResolver.cs @@ -121,6 +121,15 @@ public bool AreRoleAndOperationDefinedForEntity(string entityName, string roleNa return false; } + public bool IsStoredProcedureExecutionPermitted(string entityName, string roleName, RestMethod httpVerb) + { + bool executionPermitted = EntityPermissionsMap.TryGetValue(entityName, out EntityMetadata? entityMetadata) + && entityMetadata is not null + && entityMetadata.RoleToOperationMap.TryGetValue(roleName, out _); + + return executionPermitted; + } + /// public bool AreColumnsAllowedForOperation(string entityName, string roleName, Config.Operation operation, IEnumerable columns) { @@ -211,12 +220,24 @@ private string GetDBPolicyForRequest(string entityName, string roleName, Config. /// during runtime. /// /// - /// public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) { foreach ((string entityName, Entity entity) in runtimeConfig!.Entities) { - EntityMetadata entityToRoleMap = new(); + EntityMetadata entityToRoleMap = new() + { + ObjectType = entity.ObjectType + }; + + bool isStoredProcedureEntity = entity.ObjectType is SourceType.StoredProcedure; + if (isStoredProcedureEntity) + { + RestMethod[]? methods = entity.GetRestMethodsConfiguredForStoredProcedure(); + if (methods is not null) + { + entityToRoleMap.StoredProcedureHttpVerbs = new(methods); + } + } // Store the allowedColumns for anonymous role. // In case the authenticated role is not defined on the entity, @@ -305,7 +326,7 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) // so that it doesn't need to be evaluated per request. PopulateAllowedExposedColumns(operationToColumn.AllowedExposedColumns, entityName, allowedColumns); - IEnumerable operations = GetAllOperations(operation); + IEnumerable operations = GetAllOperationsForObjectType(operation, entity.ObjectType); foreach (Config.Operation crudOperation in operations) { // Try to add the opElement to the map if not present. @@ -317,7 +338,7 @@ public void SetEntityPermissionMap(RuntimeConfig? runtimeConfig) foreach (string allowedColumn in allowedColumns) { - entityToRoleMap.FieldToRolesMap.TryAdd(key: allowedColumn, CreateOperationToRoleMap()); + entityToRoleMap.FieldToRolesMap.TryAdd(key: allowedColumn, CreateOperationToRoleMap(entity.ObjectType)); entityToRoleMap.FieldToRolesMap[allowedColumn][crudOperation].Add(role); } @@ -387,13 +408,20 @@ private static void CopyOverPermissionsFromAnonymousToAuthenticatedRole( } /// - /// Helper method to create a list consisting of the given operation types. + /// Returns a list of all possible operations depending on the provided SourceType. + /// Stored procedures only support Operation.Execute. /// In case the operation is Operation.All (wildcard), it gets resolved to a set of CRUD operations. /// /// operation type. + /// Type of database object: Table, View, or Stored Procedure. /// IEnumerable of all available operations. - public static IEnumerable GetAllOperations(Config.Operation operation) + public static IEnumerable GetAllOperationsForObjectType(Config.Operation operation, SourceType sourceType) { + if (sourceType is SourceType.StoredProcedure) + { + return new List { Config.Operation.Execute }; + } + return operation is Config.Operation.All ? PermissionOperation.ValidPermissionOperations : new List { operation }; } @@ -650,11 +678,19 @@ private IEnumerable ResolveEntityDefinitionColumns(string entityName) /// Creates new key value map of /// Key: operationType /// Value: Collection of role names. - /// There are only four possible operations + /// There are only five possible operations /// - /// - private static Dictionary> CreateOperationToRoleMap() + /// Dictionary: Key - Operation | Value - List of roles. + private static Dictionary> CreateOperationToRoleMap(SourceType sourceType) { + if (sourceType is SourceType.StoredProcedure) + { + return new Dictionary>() + { + { Config.Operation.Execute, new List()} + }; + } + return new Dictionary>() { { Config.Operation.Create, new List()}, diff --git a/src/Service/Authorization/RestAuthorizationHandler.cs b/src/Service/Authorization/RestAuthorizationHandler.cs index e11d1b9aac..81f694fe21 100644 --- a/src/Service/Authorization/RestAuthorizationHandler.cs +++ b/src/Service/Authorization/RestAuthorizationHandler.cs @@ -4,6 +4,7 @@ using System.Net; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Models; using Microsoft.AspNetCore.Authorization; @@ -209,6 +210,38 @@ public Task HandleAsync(AuthorizationHandlerContext context) } } } + else if (requirement is StoredProcedurePermissionsRequirement) + { + if (context.Resource is not null) + { + string? entityName = context.Resource as string; + + if (entityName is null) + { + throw new DataApiBuilderException( + message: "restContext Resource Null, Something went wrong", + statusCode: HttpStatusCode.Unauthorized, + subStatusCode: DataApiBuilderException.SubStatusCodes.UnexpectedError + ); + } + + string roleName = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]; + Enum.TryParse(httpContext.Request.Method, ignoreCase: true, out RestMethod httpVerb); + bool isAuthorized = _authorizationResolver.IsStoredProcedureExecutionPermitted(entityName, roleName, httpVerb); + if (!isAuthorized) + { + context.Fail(); + } + else + { + context.Succeed(requirement); + } + } + else + { + context.Fail(); + } + } return Task.CompletedTask; } diff --git a/src/Service/Configurations/RuntimeConfigValidator.cs b/src/Service/Configurations/RuntimeConfigValidator.cs index 513ca12495..29523f17bd 100644 --- a/src/Service/Configurations/RuntimeConfigValidator.cs +++ b/src/Service/Configurations/RuntimeConfigValidator.cs @@ -191,7 +191,7 @@ public static void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(IDict if (entity.ObjectType is SourceType.StoredProcedure) { // For Stored Procedures a single query/mutation is generated. - string storedProcedureQueryName = GenerateStoredProcedureQueryName(entityName, entity); + string storedProcedureQueryName = GenerateStoredProcedureGraphQLFieldName(entityName, entity); if (!graphQLOperationNames.Add(storedProcedureQueryName)) { @@ -261,19 +261,34 @@ public static void ValidateEntityNamesInConfig(Dictionary entity } else if (entity.GraphQL is GraphQLEntitySettings graphQLSettings) { - if (graphQLSettings.Type is string graphQLName) - { - ValidateNameRequirements(graphQLName); - } - else if (graphQLSettings.Type is SingularPlural singularPluralSettings) - { - ValidateNameRequirements(singularPluralSettings.Singular); + ValidateGraphQLEntitySettings(graphQLSettings.Type); + } + else if (entity.GraphQL is GraphQLStoredProcedureEntityVerboseSettings graphQLVerboseSettings) + { + ValidateGraphQLEntitySettings(graphQLVerboseSettings.Type); + } + } + } - if (singularPluralSettings.Plural is not null) - { - ValidateNameRequirements(singularPluralSettings.Plural); - } - } + /// + /// Validates a GraphQL entity's Type configuration, which involves checking + /// whether the string value, if present, is a valid GraphQL name + /// whether the SingularPlural value, if present, are valid GraphQL names. + /// + /// object which is a string or a SingularPlural type. + private static void ValidateGraphQLEntitySettings(object? graphQLEntitySettingsType) + { + if (graphQLEntitySettingsType is string graphQLName) + { + ValidateNameRequirements(graphQLName); + } + else if (graphQLEntitySettingsType is SingularPlural singularPluralSettings) + { + ValidateNameRequirements(singularPluralSettings.Singular); + + if (singularPluralSettings.Plural is not null) + { + ValidateNameRequirements(singularPluralSettings.Plural); } } } @@ -341,10 +356,9 @@ runtimeConfig.AuthNConfig.Jwt is not null && } /// - /// Method to perform all the different validations related to the semantic correctness of the - /// runtime configuration, focusing on the permissions section of the entity. + /// Validates the semantic correctness of the permissions defined for each entity within runtime configuration. /// - /// Throws exception whenever some validation fails. + /// Throws exception when permission validation fails. public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) { foreach ((string entityName, Entity entity) in runtimeConfig.Entities) @@ -354,9 +368,9 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) foreach (PermissionSetting permissionSetting in entity.Permissions) { string roleName = permissionSetting.Role; - Object[] actions = permissionSetting.Operations; + object[] actions = permissionSetting.Operations; List operationsList = new(); - foreach (Object action in actions) + foreach (object action in actions) { if (action is null) { @@ -366,7 +380,7 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) // Evaluate actionOp as the current operation to be validated. Config.Operation actionOp; JsonElement actionJsonElement = JsonSerializer.SerializeToElement(action); - if ((actionJsonElement!).ValueKind is JsonValueKind.String) + if (actionJsonElement!.ValueKind is JsonValueKind.String) { string actionName = action.ToString()!; if (AuthorizationResolver.WILDCARD.Equals(actionName)) @@ -374,7 +388,7 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) actionOp = Config.Operation.All; } else if (!Enum.TryParse(actionName, ignoreCase: true, out actionOp) || - !IsValidPermissionAction(actionOp)) + !IsValidPermissionAction(actionOp, entity, entityName)) { throw GetInvalidActionException(entityName, roleName, actionName); } @@ -384,13 +398,13 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) PermissionOperation configOperation; try { - configOperation = JsonSerializer.Deserialize(action.ToString()!)!; + configOperation = JsonSerializer.Deserialize(action.ToString()!)!; } catch (Exception e) { throw new DataApiBuilderException( message: $"One of the action specified for entity:{entityName} is not well formed.", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError, innerException: e); } @@ -398,7 +412,7 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) actionOp = configOperation.Name; // If we have reached this point, it means that we don't have any invalid // data type in actions. However we need to ensure that the actionOp is valid. - if (!IsValidPermissionAction(actionOp)) + if (!IsValidPermissionAction(actionOp, entity, entityName)) { bool isActionPresent = ((JsonElement)action).TryGetProperty(_actionKey, out JsonElement actionElement); @@ -406,7 +420,7 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) { throw new DataApiBuilderException( message: $"action cannot be omitted for entity: {entityName}, role:{roleName}", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } @@ -425,10 +439,11 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) string misconfiguredColumnSet = configOperation.Fields.Include.Contains(AuthorizationResolver.WILDCARD) && configOperation.Fields.Include.Count > 1 ? "included" : "excluded"; string actionName = actionOp is Config.Operation.All ? "*" : actionOp.ToString(); + throw new DataApiBuilderException( message: $"No other field can be present with wildcard in the {misconfiguredColumnSet} set for:" + $" entity:{entityName}, role:{permissionSetting.Role}, action:{actionName}", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } @@ -457,26 +472,16 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) totalSupportedOperationsFromAllRoles.Add(actionOp); } - // Only one of the CRUD actions is allowed for stored procedure. - // All the roles should have the same CRUD action. + // Stored procedures only support the "execute" operation. if (entity.ObjectType is SourceType.StoredProcedure) { if ((operationsList.Count > 1) - || (operationsList.Count is 1 && operationsList[0] is Config.Operation.All)) + || (operationsList.Count is 1 && operationsList[0] is not Config.Operation.Execute)) { throw new DataApiBuilderException( message: $"Invalid Operations for Entity: {entityName}. " + - $"StoredProcedure can process only one CRUD (Create/Read/Update/Delete) operation.", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); - } - - if ((totalSupportedOperationsFromAllRoles.Count != 1)) - { - throw new DataApiBuilderException( - message: $"Invalid Operations for Entity: {entityName}. " + - $"StoredProcedure should have the same single CRUD action specified for every role.", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + $"Stored procedures can only be configured with the 'execute' operation.", + statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } } @@ -491,7 +496,7 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) /// such as "WHERE name = 'xyz'" /// /// - /// + /// True/False public bool IsValidDatabasePolicyForAction(PermissionOperation permission) { return !(permission.Policy?.Database != null && permission.Name == Config.Operation.Create); @@ -829,14 +834,44 @@ private static DataApiBuilderException GetInvalidActionException(string entityNa /// /// Returns whether the action is a valid - /// - Create, Read, Update, Delete (CRUD) operation + /// Valid non stored procedure actions: + /// - Create, Read, Update, Delete (CRUD) /// - All (*) + /// Valid stored procedure actions: + /// - Execute /// - /// + /// Compared against valid actions to determine validity. + /// Used to identify entity's representative object type. + /// Used to supplement error messages. /// Boolean value indicating whether the action is valid or not. - public static bool IsValidPermissionAction(Config.Operation action) + public static bool IsValidPermissionAction(Config.Operation action, Entity entity, string entityName) { - return action is Config.Operation.All || PermissionOperation.ValidPermissionOperations.Contains(action); + if (entity.ObjectType is SourceType.StoredProcedure) + { + if (!PermissionOperation.ValidStoredProcedurePermissionOperations.Contains(action)) + { + throw new DataApiBuilderException( + message: $"Invalid operation for Entity: {entityName}. " + + $"Stored procedures can only be configured with the 'execute' operation.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + + return true; + } + else + { + if (action is Config.Operation.Execute) + { + throw new DataApiBuilderException( + message: $"Invalid operation for Entity: {entityName}. " + + $"The 'execute' operation can only be configured for entities backed by stored procedures.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + + return action is Config.Operation.All || PermissionOperation.ValidPermissionOperations.Contains(action); + } } } } diff --git a/src/Service/Controllers/RestController.cs b/src/Service/Controllers/RestController.cs index 0f4abf9846..f6b9d968e2 100644 --- a/src/Service/Controllers/RestController.cs +++ b/src/Service/Controllers/RestController.cs @@ -184,14 +184,9 @@ private async Task HandleOperation( subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } - (string entityName, string primaryKeyRoute) = - _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); + (string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(route); - IActionResult? result - = await _restService.ExecuteAsync( - entityName, - operationType, - primaryKeyRoute); + IActionResult? result = await _restService.ExecuteAsync(entityName, operationType, primaryKeyRoute); if (result is null) { @@ -211,8 +206,7 @@ private async Task HandleOperation( // created result to the url constructed from the HttpRequest. We // then update the Location of the created result to this value. CreatedResult createdResult = (result as CreatedResult)!; - string location = - UriHelper.GetEncodedUrl(HttpContext.Request) + "/" + createdResult.Location; + string location = UriHelper.GetEncodedUrl(HttpContext.Request) + "/" + createdResult.Location; createdResult.Location = location; result = createdResult; } diff --git a/src/Service/Resolvers/SqlMutationEngine.cs b/src/Service/Resolvers/SqlMutationEngine.cs index 1684e571b4..a73666c2e8 100644 --- a/src/Service/Resolvers/SqlMutationEngine.cs +++ b/src/Service/Resolvers/SqlMutationEngine.cs @@ -83,8 +83,7 @@ public async Task> ExecuteAsync(IMiddlewareContex } Tuple? result = null; - Config.Operation mutationOperation = - MutationBuilder.DetermineMutationOperationTypeBasedOnInputType(graphqlMutationName); + Config.Operation mutationOperation = MutationBuilder.DetermineMutationOperationTypeBasedOnInputType(graphqlMutationName); // If authorization fails, an exception will be thrown and request execution halts. AuthorizeMutationFields(context, parameters, entityName, mutationOperation); @@ -727,9 +726,7 @@ public string ConstructPrimaryKeyRoute(RestRequestContext context, Dictionary /// /// - /// /// - /// /// public void AuthorizeMutationFields( IMiddlewareContext context, @@ -738,9 +735,9 @@ public void AuthorizeMutationFields( Config.Operation mutationOperation) { string role = string.Empty; - if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value)) + if (context.ContextData.TryGetValue(key: AuthorizationResolver.CLIENT_ROLE_HEADER, out object? value) && value is StringValues stringVals) { - role = (StringValues)value!.ToString(); + role = stringVals.ToString(); } if (string.IsNullOrEmpty(role)) @@ -769,12 +766,14 @@ public void AuthorizeMutationFields( isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: Config.Operation.Update, inputArgumentKeys); break; case Config.Operation.Create: - isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: Config.Operation.Create, inputArgumentKeys); + isAuthorized = _authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: role, operation: mutationOperation, inputArgumentKeys); break; + case Config.Operation.Execute: case Config.Operation.Delete: - // Delete operations are not checked for authorization on field level, - // and instead at the mutation level and would be rejected before this time in the pipeline. - // Continuing on with operation. + // Authorization is not performed for the 'execute' operation because stored procedure + // backed entities do not support column level authorization. + // Field level authorization is not supported for delete mutations. A requestor must be authorized + // to perform the delete operation on the entity to reach this point. isAuthorized = true; break; default: diff --git a/src/Service/Services/GraphQLSchemaCreator.cs b/src/Service/Services/GraphQLSchemaCreator.cs index 5706562800..29b5d26e05 100644 --- a/src/Service/Services/GraphQLSchemaCreator.cs +++ b/src/Service/Services/GraphQLSchemaCreator.cs @@ -146,10 +146,11 @@ DatabaseType.postgresql or IEnumerable rolesAllowedForEntity = _authorizationResolver.GetRolesForEntity(entityName); Dictionary> rolesAllowedForFields = new(); SourceDefinition sourceDefinition = _sqlMetadataProvider.GetSourceDefinition(entityName); - + bool isStoredProcedure = entity.ObjectType is SourceType.StoredProcedure; foreach (string column in sourceDefinition.Columns.Keys) { - IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: Config.Operation.Read); + Config.Operation operation = isStoredProcedure ? Config.Operation.Execute : Config.Operation.Read; + IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: operation); if (!rolesAllowedForFields.TryAdd(key: column, value: roles)) { throw new DataApiBuilderException( diff --git a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs index 7d1a59ad22..50ca7b373d 100644 --- a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs @@ -339,7 +339,7 @@ private async Task FillSchemaForStoredProcedureAsync( } // Generating exposed stored-procedure query/mutation name and adding to the dictionary mapping it to its entity name. - GraphQLStoredProcedureExposedNameToEntityNameMap.TryAdd(GenerateStoredProcedureQueryName(entityName, procedureEntity), entityName); + GraphQLStoredProcedureExposedNameToEntityNameMap.TryAdd(GenerateStoredProcedureGraphQLFieldName(entityName, procedureEntity), entityName); } /// @@ -420,8 +420,45 @@ private static string GetEntityPath(Entity entity, string entityName) // otherwise we have to convert each part of the Rest property we want into correct objects // they are json element so this means deserializing at each step with case insensitivity JsonSerializerOptions options = RuntimeConfig.SerializerOptions; - RestEntitySettings rest = JsonSerializer.Deserialize((JsonElement)entity.Rest, options)!; - return JsonSerializer.Deserialize((JsonElement)rest.Path, options)!; + JsonElement restConfigElement = (JsonElement)entity.Rest; + if (entity.ObjectType is SourceType.StoredProcedure) + { + if (restConfigElement.TryGetProperty("path", out JsonElement path)) + { + if (path.ValueKind is JsonValueKind.True || path.ValueKind is JsonValueKind.False) + { + bool restEnabled = JsonSerializer.Deserialize(path, options)!; + if (restEnabled) + { + return entityName; + } + else + { + return string.Empty; + } + } + else + { + return JsonSerializer.Deserialize(path, options)!; + } + } + else + { + return entityName; + } + } + else + { + RestEntitySettings rest = JsonSerializer.Deserialize((JsonElement)restConfigElement, options)!; + if (rest.Path is not null) + { + return JsonSerializer.Deserialize((JsonElement)rest.Path, options)!; + } + else + { + return entityName; + } + } } /// diff --git a/src/Service/Services/RequestValidator.cs b/src/Service/Services/RequestValidator.cs index 85bea062fb..2371780cc5 100644 --- a/src/Service/Services/RequestValidator.cs +++ b/src/Service/Services/RequestValidator.cs @@ -440,7 +440,7 @@ private static bool ValidateColumn(ColumnDefinition column, } /// - /// Validates that the entity in the request is valid. + /// Validates that the request denoted entity is defined in the runtime configuration. /// /// entity in the request. /// collection of valid entities. diff --git a/src/Service/Services/RestService.cs b/src/Service/Services/RestService.cs index ea8b1a2bf3..fc4d4dbb94 100644 --- a/src/Service/Services/RestService.cs +++ b/src/Service/Services/RestService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net; using System.Text.Json; @@ -65,7 +67,14 @@ RuntimeConfigProvider runtimeConfigProvider RequestValidator.ValidateEntity(entityName, _sqlMetadataProvider.EntityToDatabaseObject.Keys); DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; - await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement()); + if (dbObject.SourceType is not SourceType.StoredProcedure) + { + await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new EntityRoleOperationPermissionsRequirement()); + } + else + { + await AuthorizationCheckForRequirementAsync(resource: entityName, requirement: new StoredProcedurePermissionsRequirement()); + } QueryString? query = GetHttpContext().Request.QueryString; string queryString = query is null ? string.Empty : GetHttpContext().Request.QueryString.ToString(); @@ -81,7 +90,16 @@ RuntimeConfigProvider runtimeConfigProvider // If request has resolved to a stored procedure entity, initialize and validate appropriate request context if (dbObject.SourceType is SourceType.StoredProcedure) { - PopulateStoredProcedureContext(operationType, + if (!IsHttpMethodAllowedForStoredProcedure(entityName)) + { + throw new DataApiBuilderException( + message: "This operation is not supported.", + statusCode: HttpStatusCode.MethodNotAllowed, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + } + + PopulateStoredProcedureContext( + operationType, dbObject, entityName, queryString, @@ -231,9 +249,12 @@ private Task DispatchQuery(RestRequestContext context) } /// - /// Helper method to populate the context in case the database object for this request is a stored procedure + /// Populates the request context when the representative database object is a stored procedure. + /// Stored procedures support arbitrary keys in the query string, so the read operation behaves differently + /// than for requests on non-stored procedure entities. /// - private void PopulateStoredProcedureContext(Config.Operation operationType, + private void PopulateStoredProcedureContext( + Config.Operation operationType, DatabaseObject dbObject, string entityName, string queryString, @@ -279,9 +300,10 @@ private void PopulateStoredProcedureContext(Config.Operation operationType, operationType); break; default: - throw new DataApiBuilderException(message: "This operation is not supported.", - statusCode: HttpStatusCode.BadRequest, - subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); + throw new DataApiBuilderException( + message: "This operation is not supported.", + statusCode: HttpStatusCode.BadRequest, + subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } // Throws bad request if primaryKeyRoute set @@ -291,8 +313,55 @@ private void PopulateStoredProcedureContext(Config.Operation operationType, ((StoredProcedureRequestContext)context).PopulateResolvedParameters(); // Validate the request parameters - RequestValidator.ValidateStoredProcedureRequestContext( - (StoredProcedureRequestContext)context, _sqlMetadataProvider); + RequestValidator.ValidateStoredProcedureRequestContext((StoredProcedureRequestContext)context, _sqlMetadataProvider); + } + + /// + /// Returns whether the stored procedure backed entity allows the + /// request's HTTP method. e.g. when an entity is only configured for "GET" + /// and the request method is "POST" this method will return false. + /// + /// Name of the entity. + /// True if the operation is allowed. False, otherwise. + private bool IsHttpMethodAllowedForStoredProcedure(string entityName) + { + if (TryGetStoredProcedureRESTVerbs(entityName, out List? httpVerbs)) + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + if (httpContext is not null + && Enum.TryParse(httpContext.Request.Method, ignoreCase: true, out RestMethod method) + && httpVerbs.Contains(method)) + { + return true; + } + } + + return false; + } + + /// + /// Gets the list of HTTP methods defined for entities representing stored procedures. + /// When no explicit REST method configuration is present for a stored procedure entity, + /// the default method "POST" is populated in httpVerbs. + /// + /// Name of the entity. + /// Out Param: List of httpverbs configured for stored procedure backed entity. + /// True, with a list of HTTP verbs. False, when entity is not found in config + /// or entity is not a stored procedure, and httpVerbs will be null. + private bool TryGetStoredProcedureRESTVerbs(string entityName, [NotNullWhen(true)] out List? httpVerbs) + { + if (_runtimeConfigProvider.TryGetRuntimeConfiguration(out RuntimeConfig? runtimeConfig)) + { + if (runtimeConfig.Entities.TryGetValue(key: entityName, out Entity? entity) && entity is not null) + { + RestMethod[]? methods = entity.GetRestMethodsConfiguredForStoredProcedure(); + httpVerbs = methods is not null ? new List(methods) : new(); + return true; + } + } + + httpVerbs = null; + return false; } /// diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index b3e8bcb0b2..41563b3e43 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -258,7 +258,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC isRuntimeReady = PerformOnConfigChangeAsync(app).Result; if (_logger is not null && runtimeConfigProvider.RuntimeConfigPath is not null) { - _logger.LogInformation($"Loading config file: {runtimeConfigProvider.RuntimeConfigPath!.ConfigFileName}"); + _logger.LogInformation($"Loading config file: {runtimeConfigProvider.RuntimeConfigPath.ConfigFileName}"); } if (!isRuntimeReady)