GUACAMOLE-926: Also accept user and group identifiers to post in a seperate request.

This commit is contained in:
James Muehlner
2023-02-09 01:51:14 +00:00
parent 314adf6c23
commit 761438e02d
6 changed files with 522 additions and 142 deletions

View File

@@ -33,6 +33,7 @@ angular.module('import').factory('connectionCSVService',
// Required types
const ParseError = $injector.get('ParseError');
const ProtoConnection = $injector.get('ProtoConnection');
const TranslatableMessage = $injector.get('TranslatableMessage');
// Required services
@@ -43,7 +44,7 @@ angular.module('import').factory('connectionCSVService',
const service = {};
/**
* Returns a promise that resolves to an object detailing the connection
* Returns a promise that resolves to a object detailing the connection
* attributes for the current data source, as well as the connection
* paremeters for every protocol, for the current data source.
*
@@ -96,12 +97,66 @@ angular.module('import').factory('connectionCSVService',
};
});
}
/**
* Split a raw user-provided, semicolon-seperated list of identifiers into
* an array of identifiers. If identifiers contain semicolons, they can be
* escaped with backslashes, and backslashes can also be escaped using other
* backslashes.
*
* @param {type} rawIdentifiers
* The raw string value as fetched from the CSV.
*
* @returns {Array.<String>}
* An array of identifier values.
*/
function splitIdentifiers(rawIdentifiers) {
// Keep track of whether a backslash was seen
let escaped = false;
return _.reduce(rawIdentifiers, (identifiers, ch) => {
// The current identifier will be the last one in the final list
let identifier = identifiers[identifiers.length - 1];
// If a semicolon is seen, set the "escaped" flag and continue
// to the next character
if (!escaped && ch == '\\') {
escaped = true;
return identifiers;
}
// End the current identifier and start a new one if there's an
// unescaped semicolon
else if (!escaped && ch == ';') {
identifiers.push('');
return identifiers;
}
// In all other cases, just append to the identifier
else {
identifier += ch;
escaped = false;
}
// Save the updated identifier to the list
identifiers[identifiers.length - 1] = identifier;
return identifiers;
}, [''])
// Filter out any 0-length (empty) identifiers
.filter(identifier => identifier.length);
}
/**
* 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 connection object.
* If an error occurs while parsing a particular row, the resolved function
* will throw a ParseError describing the failure.
* a function that can take a CSV data row and return a ProtoConnection
* object. If an error occurs while parsing a particular row, the resolved
* function will throw a ParseError describing the failure.
*
* The provided CSV must contain columns for name and protocol. Optionally,
* the parentIdentifier of the target parent connection group, or a connection
@@ -120,14 +175,17 @@ angular.module('import').factory('connectionCSVService',
* required.
*
* This returned object will be very similar to the Connection type, with
* the exception that a human-readable "group" field may be present.
* the exception that a human-readable "group" field may be present, in
* addition to "user" and "userGroup" fields containing arrays of user and
* user group identifiers for whom read access should be granted to this
* connection.
*
* If a failure occurs while attempting to create the transformer function,
* the promise will be rejected with a ParseError describing the failure.
*
* @returns {Promise.<Function.<String[], Object>>}
* A promise that will resolve to a function that translates a CSV data
* row (array of strings) to a connection object.
* row (array of strings) to a ProtoConnection object.
*/
service.getCSVTransformer = function getCSVTransformer(headerRow) {
@@ -149,8 +207,12 @@ angular.module('import').factory('connectionCSVService',
protocolGetter: undefined,
// Callbacks for a parent group ID or group path
groupGetter: _.noop,
parentIdentifierGetter: _.noop,
groupGetter: undefined,
parentIdentifierGetter: undefined,
// Callbacks for user and user group identifiers
usersGetter: () => [],
userGroupsGetter: () => [],
// Callbacks that will generate either connection attributes or
// parameters. These callbacks will return a {type, name, value}
@@ -188,6 +250,11 @@ angular.module('import').factory('connectionCSVService',
// A callback that returns the field at the current index
const fetchFieldAtIndex = row => row[index];
// A callback that splits identifier lists by semicolon
// characters into a javascript list of identifiers
const identifierListCallback = row =>
splitIdentifiers(fetchFieldAtIndex(row));
// Set up the name callback
if (header == 'name')
@@ -203,7 +270,18 @@ angular.module('import').factory('connectionCSVService',
// Set up the group parent ID callback
else if (header == 'parentIdentifier')
transformConfig.parentIdentifierGetter = fetchFieldAtIndex;
transformConfig.parentIdentifierGetter = (
identifierListCallback);
// Set the user identifiers callback
else if (header == 'users')
transformConfig.usersGetter = (
identifierListCallback);
// Set the user group identifiers callback
else if (header == 'groups')
transformConfig.userGroupsGetter = (
identifierListCallback);
// At this point, any other header might refer to a connection
// parameter or to an attribute
@@ -282,47 +360,61 @@ angular.module('import').factory('connectionCSVService',
return { type, name, value };
});
});
const {
nameGetter, protocolGetter,
parentIdentifierGetter, groupGetter,
usersGetter, userGroupsGetter,
parameterOrAttributeGetters
} = transformConfig;
// Fail if the name wasn't provided
if (!transformConfig.nameGetter)
if (!nameGetter)
return deferred.reject(new ParseError({
message: 'The connection name must be provided',
key: 'CONNECTION_IMPORT.ERROR_REQUIRED_NAME'
}));
// Fail if the protocol wasn't provided
if (!transformConfig.protocolGetter)
if (!protocolGetter)
return deferred.reject(new ParseError({
message: 'The connection protocol must be provided',
key: 'CONNECTION_IMPORT.ERROR_REQUIRED_PROTOCOL'
}));
// If both are specified, the parent group is ambigious
if (parentIdentifierGetter && groupGetter)
throw new ParseError({
message: 'Only one of group or parentIdentifier can be set',
key: 'CONNECTION_IMPORT.ERROR_AMBIGUOUS_PARENT_GROUP'
});
// The function to transform a CSV row into a connection object
deferred.resolve(function transformCSVRow(row) {
const {
nameGetter, protocolGetter,
parentIdentifierGetter, groupGetter,
parameterOrAttributeGetters
} = transformConfig;
// Set name and protocol
// Get name and protocol
const name = nameGetter(row);
const protocol = protocolGetter(row);
// Get any users or user groups who should be granted access
const users = usersGetter(row);
const groups = userGroupsGetter(row);
// Set the parent group ID and/or group path
// Get the parent group ID and/or group path
const group = groupGetter && groupGetter(row);
const parentIdentifier = (
parentIdentifierGetter && parentIdentifierGetter(row));
return {
return new ProtoConnection({
// Simple fields that are not protocol-specific
// Fields that are not protocol-specific
...{
name,
protocol,
parentIdentifier,
group
group,
users,
groups
},
// Fields that might potentially be either attributes or
@@ -339,7 +431,7 @@ angular.module('import').factory('connectionCSVService',
}, {parameters: {}, attributes: {}})
}
});
});

View File

@@ -33,6 +33,7 @@ angular.module('import').factory('connectionParseService',
const Connection = $injector.get('Connection');
const DirectoryPatch = $injector.get('DirectoryPatch');
const ParseError = $injector.get('ParseError');
const ParseResult = $injector.get('ParseResult');
const TranslatableMessage = $injector.get('TranslatableMessage');
// Required services
@@ -41,8 +42,81 @@ 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.<String<Object.<String, *>>} 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.<Function[String[], String[]>>}
* 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.<String<Object.<String, *>>} 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.<Function.<String[], ParseError?>>}
* 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
@@ -127,9 +201,9 @@ angular.module('import').factory('connectionParseService',
/**
* Returns a promise that will resolve to a transformer function that will
* take an object that may a "group" field, replacing it if present with a
* "parentIdentifier". If both a "group" and "parentIdentifier" field are
* present on the provided object, or if no group exists at the specified
* take an object that may contain a "group" field, replacing it if present
* with a "parentIdentifier". If both a "group" and "parentIdentifier" field
* are present on the provided object, or if no group exists at the specified
* path, the function will throw a ParseError describing the failure.
*
* @returns {Promise.<Function<Object, Object>>}
@@ -170,32 +244,109 @@ angular.module('import').factory('connectionParseService',
});
}
// Translate a given javascript object to a full-fledged Connection
const connectionTransformer = connection => new Connection(connection);
/**
* Convert a provided ProtoConnection 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
* object after being run through all functions in `transformFunctions`.
*
* @param {Function[]} transformFunctions
* An array of transformation functions to run on each entry in
* `connection` data.
*
* @return {Promise.<Object>}
* A promise resolving to ParseResult object representing the result of
* parsing all provided connection data.
*/
function parseConnectionData(connectionData, transformFunctions) {
// Check that the provided connection data array is not empty
const checkError = performBasicChecks(connectionData);
if (checkError) {
const deferred = $q.defer();
deferred.reject(checkError);
return deferred.promise;
}
// Translate a Connection object to a patch requesting the creation of said
// Connection
const patchTransformer = connection => new DirectoryPatch({
op: 'add',
path: '/',
value: connection
});
return $q.all({
groupTransformer : getGroupTransformer(),
invalidUserIdErrorDetector : getInvalidIdentifierErrorChecker(
userService.getUsers),
invalidGroupIDErrorDetector : getInvalidIdentifierErrorChecker(
userGroupService.getUserGroups),
})
// 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;
// Run the array data through each provided transform
let connectionObject = data;
_.forEach(transformFunctions, transform => {
connectionObject = transform(connectionObject);
});
// All errors found while parsing this connection
const connectionErrors = [];
parseResult.errors.push(connectionErrors);
// Translate the group on the object to a parentIdentifier
try {
connectionObject = groupTransformer(connectionObject);
}
// If there was a problem with the group or parentIdentifier
catch (error) {
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);
// Translate to a full-fledged Connection
const connection = new Connection(connectionObject);
// Finally, add a patch for creating the connection
patches.push(new DirectoryPatch({
op: 'add',
path: '/',
value: connection
}));
// If there are any errors for this connection fail the whole batch
if (connectionErrors.length)
parseResult.hasErrors = true;
return parseResult;
}, new ParseResult()));
}
/**
* Convert a provided CSV representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
*
* TODO: Describe disambiguation suffixes, e.g. hostname (parameter), and
* that we will accept without the suffix if it's unambigous. (or not? how about not?)
* object to be submitted to the PATCH REST endpoint, as well as a list of
* objects containing lists of user and user group identifiers to be granted
* to each connection.
*
* @param {String} csvData
* The JSON-encoded connection list to convert to a PATCH request body.
* The CSV-encoded connection list to process.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
* @return {Promise.<Object>}
* A promise resolving to ParseResult object representing the result of
* parsing all provided connection data.
*/
service.parseCSV = function parseCSV(csvData) {
@@ -211,134 +362,69 @@ angular.module('import').factory('connectionParseService',
catch(error) {
console.error(error);
const deferred = $q.defer();
deferred.reject(error);
deferred.reject(new ParseError({ message: error.message }));
return deferred.promise;
}
// The header row - an array of string header values
const header = parsedData.length ? parsedData[0] : [];
// Slice off the header row to get the data rows
const connectionData = parsedData.slice(1);
// Check that the provided CSV is not empty (the parser always
// returns an array)
const checkError = performBasicChecks(connectionData);
if (checkError) {
const deferred = $q.defer();
deferred.reject(checkError);
return deferred.promise;
}
// The header row - an array of string header values
const header = parsedData[0];
// Generate the CSV transform function, and apply it to every row
// before applying all the rest of the standard transforms
return connectionCSVService.getCSVTransformer(header).then(
csvTransformer =>
return $q.all({
csvTransformer : connectionCSVService.getCSVTransformer(header),
groupTransformer : getGroupTransformer()
})
// Transform the rows from the CSV file to an array of API patches
.then(({csvTransformer, groupTransformer}) => connectionData.map(
dataRow => {
// Translate the raw CSV data to a javascript object
let connectionObject = csvTransformer(dataRow);
// Translate the group on the object to a parentIdentifier
connectionObject = groupTransformer(connectionObject);
// Translate to a full-fledged Connection
const connection = connectionTransformer(connectionObject);
// Finally, translate to a patch for creating the connection
return patchTransformer(connection);
}));
// Apply the CSV transform to every row
parseConnectionData(connectionData, [csvTransformer]));
};
/**
* Convert a provided YAML representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
* object to be submitted to the PATCH REST endpoint, as well as a list of
* objects containing lists of user and user group identifiers to be granted
* to each connection.
*
* @param {String} yamlData
* The YAML-encoded connection list to convert to a PATCH request body.
* The YAML-encoded connection list to process.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided YAML.
* @return {Promise.<Object>}
* A promise resolving to ParseResult object representing the result of
* parsing all provided connection data.
*/
service.parseYAML = function parseYAML(yamlData) {
// Parse from YAML into a javascript array
const parsedData = parseYAMLData(yamlData);
const connectionData = parseYAMLData(yamlData);
// Check that the data is the correct format, and not empty
const checkError = performBasicChecks(connectionData);
if (checkError) {
const deferred = $q.defer();
deferred.reject(checkError);
return deferred.promise;
}
// Transform the data from the YAML file to an array of API patches
return getGroupTransformer().then(
groupTransformer => parsedData.map(connectionObject => {
// Translate the group on the object to a parentIdentifier
connectionObject = groupTransformer(connectionObject);
// Translate to a full-fledged Connection
const connection = connectionTransformer(connectionObject);
// Finally, translate to a patch for creating the connection
return patchTransformer(connection);
}));
// Produce a ParseResult
return parseConnectionData(connectionData);
};
/**
* Convert a provided JSON representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
* Convert a provided JSON-encoded representation of a connection list into
* an array of patches to be submitted to the PATCH REST endpoint, as well
* as a list of objects containing lists of user and user group identifiers
* to be granted to each connection.
*
* @param {String} jsonData
* The JSON-encoded connection list to convert to a PATCH request body.
* The JSON-encoded connection list to process.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided JSON.
* @return {Promise.<Object>}
* A promise resolving to ParseResult object representing the result of
* parsing all provided connection data.
*/
service.parseJSON = function parseJSON(jsonData) {
// Parse from JSON into a javascript array
const parsedData = JSON.parse(yamlData);
const connectionData = JSON.parse(jsonData);
// Check that the data is the correct format, and not empty
const checkError = performBasicChecks(connectionData);
if (checkError) {
const deferred = $q.defer();
deferred.reject(checkError);
return deferred.promise;
}
// Produce a ParseResult
return parseConnectionData(connectionData);
// Transform the data from the YAML file to an array of API patches
return getGroupTransformer().then(
groupTransformer => parsedData.map(connectionObject => {
// Translate the group on the object to a parentIdentifier
connectionObject = groupTransformer(connectionObject);
// Translate to a full-fledged Connection
const connection = connectionTransformer(connectionObject);
// Finally, translate to a patch for creating the connection
return patchTransformer(connection);
}));
};
return service;

View File

@@ -31,7 +31,7 @@ angular.module('import').factory('ParseError', [function defineParseError() {
* The object whose properties should be copied within the new
* ParseError.
*/
var ParseError = function ParseError(template) {
const ParseError = function ParseError(template) {
// Use empty object by default
template = template || {};

View File

@@ -0,0 +1,87 @@
/*
* 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 ParseResult class.
*/
angular.module('import').factory('ParseResult', [function defineParseResult() {
/**
* The result of parsing a connection import file - containing a list of
* API patches ready to be submitted to the PATCH REST API for batch
* connection creation, a set of users and user groups to grant access to
* each connection, and any errors that may have occured while parsing
* each connection.
*
* @constructor
* @param {ParseResult|Object} [template={}]
* The object whose properties should be copied within the new
* ParseResult.
*/
const ParseResult = function ParseResult(template) {
// Use empty object by default
template = template || {};
/**
* An array of patches, ready to be submitted to the PATCH REST API for
* batch connection creation.
*
* @type {DirectoryPatch[]}
*/
this.patches = template.patches || [];
/**
* A list of user identifiers that should be granted read access to the
* the corresponding connection (at the same array index).
*
* @type {String[]}
*/
this.users = template.users || [];
/**
* A list of user group identifiers that should be granted read access
* to the corresponding connection (at the same array index).
*
* @type {String[]}
*/
this.groups = template.groups || [];
/**
* An array of errors encountered while parsing the corresponding
* connection (at the same array index). Each connection should have a
* an array of errors. If empty, no errors occured for this connection.
*
* @type {ParseError[][]}
*/
this.errors = template.errors || [];
/**
* True if any errors were encountered while parsing the connections
* represented by this ParseResult. This should always be true if there
* are a non-zero number of elements in the errors list for any
* connection, or false otherwise.
*/
this.hasErrors = template.hasErrors || false;
};
return ParseResult;
}]);

View File

@@ -0,0 +1,111 @@
/*
* 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 Connection class.
*/
angular.module('import').factory('ProtoConnection', [
function defineProtoConnection() {
/**
* A representation of a connection to be imported, as parsed from an
* user-supplied import file.
*
* @constructor
* @param {Connection|Object} [template={}]
* The object whose properties should be copied within the new
* Connection.
*/
var ProtoConnection = function ProtoConnection(template) {
// Use empty object by default
template = template || {};
/**
* The unique identifier of the connection group that contains this
* connection.
*
* @type String
*/
this.parentIdentifier = template.parentIdentifier;
/**
* The path to the connection group that contains this connection,
* written as e.g. "ROOT/parent/child/group".
*
* @type String
*/
this.group = template.group;
/**
* The human-readable name of this connection, which is not necessarily
* unique.
*
* @type String
*/
this.name = template.name;
/**
* The name of the protocol associated with this connection, such as
* "vnc" or "rdp".
*
* @type String
*/
this.protocol = template.protocol;
/**
* Connection configuration parameters, as dictated by the protocol in
* use, arranged as name/value pairs. This information may not be
* available until directly queried. If this information is
* unavailable, this property will be null or undefined.
*
* @type Object.<String, String>
*/
this.parameters = template.parameters || {};
/**
* Arbitrary name/value pairs which further describe this connection.
* The semantics and validity of these attributes are dictated by the
* extension which defines them.
*
* @type Object.<String, String>
*/
this.attributes = template.attributes || {};
/**
* The identifiers of all users who should be granted read access to
* this connection.
*
* @type String[]
*/
this.users = template.users || [];
/**
* The identifiers of all user groups who should be granted read access
* to this connection.
*
* @type String[]
*/
this.groups = template.groups || [];
};
return ProtoConnection;
}]);

View File

@@ -200,6 +200,10 @@
"ERROR_INVALID_GROUP": "No group matching \"{GROUP}\" found",
"ERROR_INVALID_FILE_TYPE":
"Invalid import file type \"{TYPE}\"",
"ERROR_INVALID_USER_IDENTIFIERS":
"Users not found: {IDENTIFIER_LIST}",
"ERROR_INVALID_USER_GROUP_IDENTIFIERS":
"User Groups not found: {IDENTIFIER_LIST}",
"ERROR_NO_FILE_SUPPLIED": "Please select a file to import",
"ERROR_AMBIGUOUS_PARENT_GROUP":
"Both group and parentIdentifier may be not specified at the same time",