From bfb7f3b78a7699183d8255b6bf3415d9fc32c2b1 Mon Sep 17 00:00:00 2001 From: James Muehlner Date: Mon, 13 Feb 2023 19:02:26 +0000 Subject: [PATCH] GUACAMOLE-926: Create users and groups; don't require them to exist beforehand. --- .../importConnectionsController.js | 79 +++++++++- .../import/services/connectionCSVService.js | 8 +- .../import/services/connectionParseService.js | 135 +++++------------- ...ProtoConnection.js => ImportConnection.js} | 12 +- .../src/app/import/types/ParseResult.js | 17 +++ .../app/rest/services/connectionService.js | 6 +- .../src/app/rest/types/DirectoryPatch.js | 10 +- .../app/rest/types/DirectoryPatchOutcome.js | 10 +- .../app/rest/types/DirectoryPatchResponse.js | 50 +++++++ 9 files changed, 195 insertions(+), 132 deletions(-) rename guacamole/src/main/frontend/src/app/import/types/{ProtoConnection.js => ImportConnection.js} (91%) create mode 100644 guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js diff --git a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js index 73164a08c..2d7cdab22 100644 --- a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js +++ b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js @@ -24,19 +24,73 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ function importConnectionsController($scope, $injector) { // Required services + const $routeParams = $injector.get('$routeParams'); const connectionParseService = $injector.get('connectionParseService'); const connectionService = $injector.get('connectionService'); // Required types + const DirectoryPatch = $injector.get('DirectoryPatch'); const ParseError = $injector.get('ParseError'); const TranslatableMessage = $injector.get('TranslatableMessage'); + + /** + * Given a successful response to an import PATCH request, make another + * request to delete every created connection in the provided request, i.e. + * clean up every connection that was created. + * + * @param {DirectoryPatchResponse} creationResponse + */ + function cleanUpConnections(creationResponse) { + + // The patches to delete - one delete per initial creation + const deletionPatches = creationResponse.patches.map(patch => + new DirectoryPatch({ + op: 'remove', + path: '/' + patch.identifier + })); + + console.log("Deletion Patches", deletionPatches); + + connectionService.patchConnections( + $routeParams.dataSource, deletionPatches) - function handleSuccess(data) { - console.log("OMG SUCCESS: ", data) + .then(deletionResponse => + console.log("Deletion response", deletionResponse)) + .catch(handleParseError); + + } + + /** + * Process a successfully parsed import file, creating any specified + * connections, creating and granting permissions to any specified users + * and user groups. + * + * TODO: + * - Do batch import of connections + * - Create all users/groups not already present + * - Grant permissions to all users/groups as defined in the import file + * - On failure: Roll back everything (maybe ask the user first): + * - Attempt to delete all created connections + * - Attempt to delete any created users / groups + * + * @param {ParseResult} parseResult + * The result of parsing the user-supplied import file. + * + */ + function handleParseSuccess(parseResult) { + connectionService.patchConnections( + $routeParams.dataSource, parseResult.patches) + + .then(response => { + console.log("Creation Response", response); + + // TODON'T: Delete connections so we can test over and over + cleanUpConnections(response); + }); } // Set any caught error message to the scope for display - const handleError = error => { + const handleParseError = error => { console.error(error); $scope.error = error; } @@ -44,14 +98,25 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ // Clear the current error const clearError = () => delete $scope.error; - function processData(type, data) { + /** + * Process the uploaded import file, importing the connections, granting + * connection permissions, or displaying errors to the user if there are + * problems with the provided file. + * + * @param {String} mimeType + * The MIME type of the uploaded data file. + * + * @param {String} data + * The raw string contents of the import file. + */ + function processData(mimeType, data) { // The function that will process all the raw data and return a list of // patches to be submitted to the API let processDataCallback; // Parse the data based on the provided mimetype - switch(type) { + switch(mimeType) { case "application/json": case "text/json": @@ -82,10 +147,10 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ processDataCallback(data) // Send the data off to be imported if parsing is successful - .then(handleSuccess) + .then(handleParseSuccess) // Display any error found while parsing the file - .catch(handleError); + .catch(handleParseError); } $scope.upload = function() { diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js index a104cdd46..492b9388b 100644 --- a/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js +++ b/guacamole/src/main/frontend/src/app/import/services/connectionCSVService.js @@ -33,7 +33,7 @@ angular.module('import').factory('connectionCSVService', // Required types const ParseError = $injector.get('ParseError'); - const ProtoConnection = $injector.get('ProtoConnection'); + const ImportConnection = $injector.get('ImportConnection'); const TranslatableMessage = $injector.get('TranslatableMessage'); // Required services @@ -154,7 +154,7 @@ angular.module('import').factory('connectionCSVService', /** * Given a CSV header row, create and return a promise that will resolve to - * a function that can take a CSV data row and return a ProtoConnection + * a function that can take a CSV data row and return a ImportConnection * object. If an error occurs while parsing a particular row, the resolved * function will throw a ParseError describing the failure. * @@ -185,7 +185,7 @@ angular.module('import').factory('connectionCSVService', * * @returns {Promise.>} * A promise that will resolve to a function that translates a CSV data - * row (array of strings) to a ProtoConnection object. + * row (array of strings) to a ImportConnection object. */ service.getCSVTransformer = function getCSVTransformer(headerRow) { @@ -405,7 +405,7 @@ angular.module('import').factory('connectionCSVService', const parentIdentifier = ( parentIdentifierGetter && parentIdentifierGetter(row)); - return new ProtoConnection({ + return new ImportConnection({ // Fields that are not protocol-specific ...{ diff --git a/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js index f2f6c1daa..7c4f6c754 100644 --- a/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js +++ b/guacamole/src/main/frontend/src/app/import/services/connectionParseService.js @@ -42,81 +42,8 @@ angular.module('import').factory('connectionParseService', const schemaService = $injector.get('schemaService'); const connectionCSVService = $injector.get('connectionCSVService'); const connectionGroupService = $injector.get('connectionGroupService'); - const userService = $injector.get('userService'); - const userGroupService = $injector.get('userGroupService'); const service = {}; - - /** - * Resolves to an object whose keys are all valid identifiers in the current - * data source. The provided `requestFunction` should resolve to such an - * object when provided with the current data source. - * - * @param {Function.>} requestFunction - * A function that, given a data source, will return a promise resolving - * to an object with keys that are unique identifiers for entities in - * that data source. - * - * @returns {Promise.>} - * A promise that will resolve to an function that, given an array of - * identifiers, will return all identifiers that do not exist as keys in - * the object returned by `requestFunction`, i.e. all identifiers that - * do not exist in the current data source. - */ - function getIdentifierMap(requestFunction) { - - // The current data source to which all the identifiers will belong - const dataSource = $routeParams.dataSource; - - // Make the request and return the response, which should be an object - // whose keys are all valid identifiers for the current data source - return requestFunction(dataSource); - } - - /** - * Return a promise that resolves to a function that takes an array of - * identifiers, returning a ParseError describing the invalid identifiers - * from the list. The provided `requestFunction` should resolve to such an - * object whose keys are all valid identifiers when provided with the - * current data source. - * - * @param {Function.>} requestFunction - * A function that, given a data source, will return a promise resolving - * to an object with keys that are unique identifiers for entities in - * that data source. - * - * @returns {Promise.>} - * A promise that will resolve to a function to check the validity of - * each identifier in a provided array, returning a ParseError - * describing the problem if any are not valid. - */ - function getInvalidIdentifierErrorChecker(requestFunction) { - - // Fetch all the valid user identifiers in the system, and - return getIdentifierMap(requestFunction).then(validIdentifiers => - - // The resolved function that takes a list of user group identifiers - allIdentifiers => { - - // Filter to only include invalid identifiers - const invalidIdentifiers = _.filter(allIdentifiers, - identifier => !validIdentifiers[identifier]); - - if (invalidIdentifiers.length) { - - // Quote and comma-seperate for display - const identifierList = invalidIdentifiers.map( - identifier => '"' + identifier + '"').join(', '); - - return new ParseError({ - message: 'Invalid User Group Identifiers: ' + identifierList, - key: 'CONNECTION_IMPORT.ERROR_INVALID_USER_GROUP_IDENTIFIERS', - variables: { IDENTIFIER_LIST: identifierList } - }); - } - - }); - } /** * Perform basic checks, common to all file types - namely that the parsed @@ -245,12 +172,12 @@ angular.module('import').factory('connectionParseService', } /** - * Convert a provided ProtoConnection array into a ParseResult. Any provided + * Convert a provided ImportConnection array into a ParseResult. Any provided * transform functions will be run on each entry in `connectionData` before * any other processing is done. * * @param {*[]} connectionData - * An arbitrary array of data. This must evaluate to a ProtoConnection + * An arbitrary array of data. This must evaluate to a ImportConnection * object after being run through all functions in `transformFunctions`. * * @param {Function[]} transformFunctions @@ -271,21 +198,10 @@ angular.module('import').factory('connectionParseService', return deferred.promise; } - return $q.all({ - groupTransformer : getGroupTransformer(), - invalidUserIdErrorDetector : getInvalidIdentifierErrorChecker( - userService.getUsers), - invalidGroupIDErrorDetector : getInvalidIdentifierErrorChecker( - userGroupService.getUserGroups), - }) + return getGroupTransformer().then(groupTransformer => + connectionData.reduce((parseResult, data) => { - // Transform the rows from the CSV file to an array of API patches - // and lists of user and group identifiers - .then(({groupTransformer, - invalidUserIdErrorDetector, invalidGroupIDErrorDetector}) => - connectionData.reduce((parseResult, data) => { - - const { patches, identifiers, users, groups } = parseResult; + const { patches, users, groups, allUsers, allGroups } = parseResult; // Run the array data through each provided transform let connectionObject = data; @@ -307,14 +223,15 @@ angular.module('import').factory('connectionParseService', connectionErrors.push(error); } - // Push any errors for invalid user or user group identifiers - const pushError = error => error && connectionErrors.push(error); - pushError(invalidUserIdErrorDetector(connectionObject.users)); - pushError(invalidGroupIDErrorDetector(connectionObject.userGroups)); - // Add the user and group identifiers for this connection - users.push(connectionObject.users); - groups.push(connectionObject.groups); + const connectionUsers = connectionObject.users || []; + const connectionGroups = connectionObject.groups || []; + users.push(connectionUsers); + groups.push(connectionGroups); + + // Add all user and user group identifiers to the overall sets + connectionUsers.forEach(identifier => allUsers[identifier] = true); + connectionGroups.forEach(identifier => allGroups[identifier] = true); // Translate to a full-fledged Connection const connection = new Connection(connectionObject); @@ -398,7 +315,18 @@ angular.module('import').factory('connectionParseService', service.parseYAML = function parseYAML(yamlData) { // Parse from YAML into a javascript array - const connectionData = parseYAMLData(yamlData); + try { + const connectionData = parseYAMLData(yamlData); + } + + // If the YAML parser throws an error, reject with that error. No + // translation key will be available here. + catch(error) { + console.error(error); + const deferred = $q.defer(); + deferred.reject(new ParseError({ message: error.message })); + return deferred.promise; + } // Produce a ParseResult return parseConnectionData(connectionData); @@ -420,7 +348,18 @@ angular.module('import').factory('connectionParseService', service.parseJSON = function parseJSON(jsonData) { // Parse from JSON into a javascript array - const connectionData = JSON.parse(jsonData); + try { + const connectionData = JSON.parse(jsonData); + } + + // If the JSON parse attempt throws an error, reject with that error. + // No translation key will be available here. + catch(error) { + console.error(error); + const deferred = $q.defer(); + deferred.reject(new ParseError({ message: error.message })); + return deferred.promise; + } // Produce a ParseResult return parseConnectionData(connectionData); diff --git a/guacamole/src/main/frontend/src/app/import/types/ProtoConnection.js b/guacamole/src/main/frontend/src/app/import/types/ImportConnection.js similarity index 91% rename from guacamole/src/main/frontend/src/app/import/types/ProtoConnection.js rename to guacamole/src/main/frontend/src/app/import/types/ImportConnection.js index c853934d0..9f336ef77 100644 --- a/guacamole/src/main/frontend/src/app/import/types/ProtoConnection.js +++ b/guacamole/src/main/frontend/src/app/import/types/ImportConnection.js @@ -18,21 +18,21 @@ */ /** - * Service which defines the Connection class. + * Service which defines the ImportConnection class. */ -angular.module('import').factory('ProtoConnection', [ - function defineProtoConnection() { +angular.module('import').factory('ImportConnection', [ + function defineImportConnection() { /** * A representation of a connection to be imported, as parsed from an * user-supplied import file. * * @constructor - * @param {Connection|Object} [template={}] + * @param {ImportConnection|Object} [template={}] * The object whose properties should be copied within the new * Connection. */ - var ProtoConnection = function ProtoConnection(template) { + var ImportConnection = function ImportConnection(template) { // Use empty object by default template = template || {}; @@ -106,6 +106,6 @@ angular.module('import').factory('ProtoConnection', [ }; - return ProtoConnection; + return ImportConnection; }]); \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/import/types/ParseResult.js b/guacamole/src/main/frontend/src/app/import/types/ParseResult.js index e6aa59229..77c49eb31 100644 --- a/guacamole/src/main/frontend/src/app/import/types/ParseResult.js +++ b/guacamole/src/main/frontend/src/app/import/types/ParseResult.js @@ -63,6 +63,23 @@ angular.module('import').factory('ParseResult', [function defineParseResult() { */ this.groups = template.groups || []; + /** + * An object whose keys are the user identifiers of every user specified + * in the batch import. i.e. a set of all user identifiers. + * + * @type {Object.} + */ + this.allUsers = template.allUsers || {}; + + /** + * An object whose keys are the user group identifiers of every user + * group specified in the batch import. i.e. a set of all user group + * identifiers. + * + * @type {Object.} + */ + this.allGroups = template.allGroups || {}; + /** * An array of errors encountered while parsing the corresponding * connection (at the same array index). Each connection should have a diff --git a/guacamole/src/main/frontend/src/app/rest/services/connectionService.js b/guacamole/src/main/frontend/src/app/rest/services/connectionService.js index 053559530..5c1451ec2 100644 --- a/guacamole/src/main/frontend/src/app/rest/services/connectionService.js +++ b/guacamole/src/main/frontend/src/app/rest/services/connectionService.js @@ -158,7 +158,7 @@ angular.module('rest').factory('connectionService', ['$injector', /** * Makes a request to the REST API to apply a supplied list of connection * patches, returning a promise that can be used for processing the results - * of the call. + * of the call. * * This operation is atomic - if any errors are encountered during the * connection patching process, the entire request will fail, and no @@ -181,12 +181,14 @@ angular.module('rest').factory('connectionService', ['$injector', }) // Clear the cache - .then(function connectionsPatched(){ + .then(function connectionsPatched(patchResponse){ cacheService.connections.removeAll(); // Clear users cache to force reload of permissions for any // newly created or replaced connections cacheService.users.removeAll(); + + return patchResponse; }); diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js index 96a2cb093..05874d4fc 100644 --- a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js +++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatch.js @@ -59,10 +59,9 @@ angular.module('rest').factory('DirectoryPatch', [function defineDirectoryPatch( this.path = template.path || '/'; /** - * The object being added or replaced, or the identifier of the object - * being removed. + * The object being added, or undefined if deleting. * - * @type {DirectoryObject|String} + * @type {DirectoryObject} */ this.value = template.value; @@ -78,11 +77,6 @@ angular.module('rest').factory('DirectoryPatch', [function defineDirectoryPatch( */ ADD : "add", - /** - * Removes the specified object from the relation. - */ - REPLACE : "replace", - /** * Removes the specified object from the relation. */ diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js index 4534dae13..e6710c2ca 100644 --- a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js +++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchOutcome.js @@ -30,15 +30,11 @@ angular.module('rest').factory('DirectoryPatchOutcome', [ * response. The error field is only meaningful for unsuccessful patches. * @constructor * - * @template DirectoryObject - * The directory-based object type that this DirectoryPatchOutcome - * represents a patch outcome for. - * - * @param {DirectoryObject|Object} [template={}] + * @param {DirectoryPatchOutcome|Object} [template={}] * The object whose properties should be copied within the new * DirectoryPatchOutcome. */ - var DirectoryPatchOutcome = function DirectoryPatchOutcome(template) { + const DirectoryPatchOutcome = function DirectoryPatchOutcome(template) { // Use empty object by default template = template || {}; @@ -78,6 +74,6 @@ angular.module('rest').factory('DirectoryPatchOutcome', [ }; - return DirectoryPatch; + return DirectoryPatchOutcome; }]); diff --git a/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js new file mode 100644 index 000000000..9538c077e --- /dev/null +++ b/guacamole/src/main/frontend/src/app/rest/types/DirectoryPatchResponse.js @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Service which defines the DirectoryPatchResponse class. + */ +angular.module('rest').factory('DirectoryPatchResponse', [ + function defineDirectoryPatchResponse() { + + /** + * An object returned by a PATCH request to a directory REST API, + * representing the successful response to a patch request. + * + * @param {DirectoryPatchResponse|Object} [template={}] + * The object whose properties should be copied within the new + * DirectoryPatchResponse. + */ + const DirectoryPatchResponse = function DirectoryPatchResponse(template) { + + // Use empty object by default + template = template || {}; + + /** + * An outcome for each patch in the corresponding patch request. + * + * @type {DirectoryPatchOutcome[]} + */ + this.patches = template.patches; + + }; + + return DirectoryPatchResponse; + +}]);