001package com.identityworksllc.iiq.common.task;
002
003import com.identityworksllc.iiq.common.Functions;
004import com.identityworksllc.iiq.common.iterators.TransformingIterator;
005import com.identityworksllc.iiq.common.query.ContextConnectionWrapper;
006import org.apache.commons.logging.Log;
007import org.apache.commons.logging.LogFactory;
008import sailpoint.api.IncrementalObjectIterator;
009import sailpoint.api.ObjectUtil;
010import sailpoint.api.SailPointContext;
011import sailpoint.connector.Connector;
012import sailpoint.connector.ConnectorException;
013import sailpoint.connector.ConnectorFactory;
014import sailpoint.object.*;
015import sailpoint.tools.CloseableIterator;
016import sailpoint.tools.GeneralException;
017import sailpoint.tools.RFC4180LineParser;
018import sailpoint.tools.Util;
019
020import java.io.BufferedReader;
021import java.io.File;
022import java.io.FileInputStream;
023import java.io.IOException;
024import java.io.InputStreamReader;
025import java.sql.Connection;
026import java.sql.PreparedStatement;
027import java.sql.ResultSet;
028import java.sql.SQLException;
029import java.util.ArrayList;
030import java.util.Collections;
031import java.util.HashMap;
032import java.util.Iterator;
033import java.util.List;
034import java.util.Map;
035import java.util.Objects;
036import java.util.function.Consumer;
037import java.util.function.Function;
038
039/**
040 * Implements a reusable implementation of {@link ObjectRetriever} for use with any
041 * number of scheduled tasks.
042 *
043 * This class takes the following input arguments in its constructor:
044 *
045 *  - values: If the input type is 'provided', these values will be interpreted as the raw input.
046 *  - retrievalSql: A single-column SQL query that will produce a list of strings. The first column must be a string. Other columns will be ignored.
047 *  - retrievalSqlClass: If specified, the single-column result from the query will be used as an ID look up the object with this type
048 *  - retrievalConnector: The name of an Application; the iteration will be over a list of ResourceObjects as though an aggregation is running
049 *  - retrievalFile: The fully qualified name of an existing, readable text file. Each line will be passed as a List of Strings to convertObject. If a delimiter is specified, the List will contain each delimited element. If not, the list will contain the entire line.
050 *  - retrievalFileDelimiter: If specified, the file will be interpreted as a CSV; each line as a List will be passed to threadExecute
051 *  - retrievalFileClass: If specified, the file will be interpreted as a CSV and the first column will be interpreted as an ID or name of this class type.
052 *  - retrievalRule: The name of a Rule that will produce an Iterator over the desired objects
053 *  - retrievalScript: The source of a Script that will produce an Iterator over the desired objects
054 *  - retrievalFilter: A Filter string combined with retrievalFilterClass to do a search in SailPoint
055 *  - retrievalFilterClass: The class name to search with the retrievalFilter
056 *
057 * You are expected to provide a {@link TransformingIterator} supplier that will convert
058 * from the output type listed below to the desired type. If your TransformingIterator
059 * does not produce the correct output, it is likely that you will run into ClassCastExceptions.
060 * That is beyond the scope of this class.
061 *
062 * Default object types if you do not convert them afterwards are:
063 *
064 *  'provided':
065 *    String
066 *
067 *  'sql':
068 *    If a class is specified, instances of that class. If not, strings.
069 *
070 *  'connector':
071 *    ResourceObject
072 *
073 *  'file':
074 *    List of Strings
075 *
076 *  'rule' and 'script':
077 *    Arbitrary. You are expected to return the right data type from your rule.
078 *    You may return an IncrementalObjectIterator, an Iterator, an Iterable, or
079 *    any other object (which will be wrapped in a singleton list).
080 *
081 *  'filter':
082 *    An object of the specified filter class, or Identity if one is not specified.
083 *    The iterator will wrap an IncrementalObjectIterator so that not all objects
084 *    are loaded into memory at once.
085 *
086 * If you are using this class in a context that can be interrupted (e.g., in a job),
087 * you will need to provide a way for this class to register a "termination handler"
088 * by calling {@link #setTerminationRegistrar(Consumer)}. Your termination logic
089 * MUST invoke any callbacks registered with your consumer.
090 *
091 * * @param <ItemType>
092 */
093public class BasicObjectRetriever<ItemType> implements ObjectRetriever<ItemType> {
094    /**
095     * The list of retrieval types
096     */
097    enum RetrievalType {
098        rule,
099        script,
100        sql,
101        filter,
102        connector,
103        file,
104        provided
105    }
106
107    /**
108     * The class logger
109     */
110    private final Log log;
111    /**
112     * The list of provided values
113     */
114    private List<String> providedValues;
115    /**
116     * The application used to retrieve any inputs
117     */
118    private Application retrievalApplication;
119    /**
120     * The file used to retrieve any inputs
121     */
122    private File retrievalFile;
123    /**
124     * Retrieval file delimiter
125     */
126    private String retrievalFileDelimiter;
127    /**
128     * The class to retrieve, assuming the default retriever is used
129     */
130    private Class<? extends SailPointObject> retrievalFileClass;
131    /**
132     * The filter used to retrieve any objects, assuming the default retriever is used
133     */
134    private Filter retrievalFilter;
135    /**
136     * The class to retrieve, assuming the default retriever is used
137     */
138    private Class<? extends SailPointObject> retrievalFilterClass;
139    /**
140     * A rule that returns objects to be iterated over in parallel
141     */
142    private Rule retrievalRule;
143    /**
144     * A script that returns objects to be iterated over in parallel
145     */
146    private Script retrievalScript;
147    /**
148     * A SQL query that returns IDs of objects to be iterated in parallel
149     */
150    private String retrievalSql;
151    /**
152     * The class represented by the SQL output
153     */
154    private Class<? extends SailPointObject> retrievalSqlClass;
155    /**
156     * The type of retrieval, which must be one of the defined values
157     */
158    private RetrievalType retrievalType;
159
160    /**
161     * The TaskResult of the running task
162     */
163    private final TaskResult taskResult;
164
165    /**
166     * The callback for registering termination handlers, set by setTerminationRegistrar
167     */
168    private Consumer<Functions.GenericCallback> terminationRegistrar;
169
170    /**
171     * The callback used to create the output iterator to the given type
172     */
173    private final Function<Iterator<?>, TransformingIterator<Object, ItemType>> transformerConstructor;
174
175    /**
176     * Constructs a new Basic object retriever
177     * @param context The Sailpoint context to use for searching
178     * @param arguments The arguments to the task (or other)
179     * @param transformerConstructor The callback that creates a transforming iterator
180     * @param taskResult The task result, or null
181     * @throws GeneralException if any failures occur
182     */
183    public BasicObjectRetriever(SailPointContext context, Attributes<String, Object> arguments, Function<Iterator<?>, TransformingIterator<Object, ItemType>> transformerConstructor, TaskResult taskResult) throws GeneralException {
184        Objects.requireNonNull(context);
185        Objects.requireNonNull(arguments);
186
187        initialize(context, arguments);
188
189        this.log = LogFactory.getLog(BasicObjectRetriever.class);
190        this.transformerConstructor = transformerConstructor;
191        this.taskResult = taskResult;
192    }
193
194
195    /**
196     * Retrieves the contents of an input file
197     * @return An iterator over the contents of the file
198     * @throws IOException if there is an error reading the file
199     * @throws GeneralException if there is an error doing anything Sailpoint-y
200     */
201    private Iterator<List<String>> getFileContents() throws IOException, GeneralException {
202        Iterator<List<String>> items;
203        List<List<String>> itemList = new ArrayList<>();
204        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(retrievalFile)))) {
205            RFC4180LineParser parser = null;
206            if (Util.isNotNullOrEmpty(retrievalFileDelimiter)) {
207                parser = new RFC4180LineParser(retrievalFileDelimiter);
208            }
209            String line;
210            while((line = reader.readLine()) != null) {
211                if (Util.isNotNullOrEmpty(line.trim())) {
212                    if (parser != null) {
213                        List<String> item = parser.parseLine(line);
214                        itemList.add(item);
215                    } else {
216                        List<String> lineList = Collections.singletonList(line);
217                        itemList.add(lineList);
218                    }
219                }
220            }
221        }
222        items = itemList.iterator();
223        return items;
224    }
225
226    /**
227     * Gets the object iterator, which will be an instance of {@link TransformingIterator} to
228     * convert whatever are the inputs. See the class Javadoc for information about what the
229     * default return types from each query method are.
230     *
231     * @param context The context to use during querying
232     * @param arguments Any additional query arguments
233     * @return An iterator over the retrieved objects
234     * @throws GeneralException if any failures occur
235     */
236    @Override
237    @SuppressWarnings("unchecked")
238    public Iterator<ItemType> getObjectIterator(SailPointContext context, Attributes<String, Object> arguments) throws GeneralException {
239        Objects.requireNonNull(context);
240
241        Map<String, Object> retrievalParams = new HashMap<>();
242        retrievalParams.put("log", log);
243        retrievalParams.put("context", context);
244        retrievalParams.put("taskResult", taskResult);
245        TransformingIterator<?, ItemType> items = null;
246        if (retrievalType == RetrievalType.provided) {
247            items = transformerConstructor.apply(providedValues.iterator());
248        } else if (retrievalType == RetrievalType.rule) {
249            if (retrievalRule == null) {
250                throw new IllegalArgumentException("Retrieval rule must exist");
251            }
252            Object output = context.runRule(retrievalRule, retrievalParams);
253            if (output instanceof IncrementalObjectIterator) {
254                items = transformerConstructor.apply((IncrementalObjectIterator)output);
255            } else if (output instanceof Iterator) {
256                items = transformerConstructor.apply((Iterator)output);
257            } else if (output instanceof Iterable) {
258                items = transformerConstructor.apply(((Iterable)output).iterator());
259            } else {
260                items = transformerConstructor.apply(Collections.singletonList(output).iterator());
261            }
262        } else if (retrievalType == RetrievalType.script) {
263            if (retrievalScript == null) {
264                throw new IllegalArgumentException("A retrieval script must be defined with 'script' retrieval type");
265            }
266            if (log.isDebugEnabled()) {
267                log.debug("Running retrieval script: " + retrievalScript);
268            }
269            Object output = context.runScript(retrievalScript, retrievalParams);
270            if (output instanceof IncrementalObjectIterator) {
271                items = transformerConstructor.apply((IncrementalObjectIterator)output);
272            } else if (output instanceof Iterator) {
273                items = transformerConstructor.apply((Iterator)output);
274            } else if (output instanceof Iterable) {
275                items = transformerConstructor.apply(((Iterable)output).iterator());
276            } else {
277                items = transformerConstructor.apply(Collections.singletonList(output).iterator());
278            }
279        } else if (retrievalType == RetrievalType.filter) {
280            if (retrievalFilter == null || retrievalFilterClass == null) {
281                throw new IllegalArgumentException("A retrieval filter and class name must be specified for 'filter' retrieval type");
282            }
283            QueryOptions options = new QueryOptions();
284            options.addFilter(retrievalFilter);
285            items = transformerConstructor.apply(new IncrementalObjectIterator<>(context, retrievalFilterClass, options));
286        } else if (retrievalType == RetrievalType.file) {
287            try {
288                Iterator<List<String>> fileContents = getFileContents();
289                if (retrievalFileClass != null) {
290                    items = transformerConstructor.apply(new TransformingIterator<>(fileContents, (id) -> Util.isEmpty(id) ? null : (ItemType) context.getObject(retrievalFileClass, Util.otoa(id.get(0)))));
291                } else {
292                    items = transformerConstructor.apply(fileContents);
293                }
294            } catch (IOException e) {
295                throw new GeneralException(e);
296            }
297        } else if (retrievalType == RetrievalType.sql) {
298            List<String> objectNames = new ArrayList<>();
299            // There is probably a more efficient way to do this, but it's not clear
300            // how to automatically clean up the SQL resources afterwards...
301            try (Connection connection = ContextConnectionWrapper.getConnection(context)) {
302                try (PreparedStatement stmt = connection.prepareStatement(retrievalSql)) {
303                    try (ResultSet results = stmt.executeQuery()) {
304                        while(results.next()) {
305                            String output = results.getString(1);
306                            if (Util.isNotNullOrEmpty(output)) {
307                                objectNames.add(output);
308                            }
309                        }
310                    }
311                }
312            } catch (SQLException e) {
313                throw new GeneralException(e);
314            }
315            if (retrievalSqlClass != null) {
316                items = transformerConstructor.apply(new TransformingIterator<>(objectNames.iterator(), (id) -> (ItemType) context.getObject(retrievalSqlClass, id)));
317            } else {
318                items = transformerConstructor.apply(objectNames.iterator());
319            }
320        } else if (retrievalType == RetrievalType.connector) {
321            if (retrievalApplication == null) {
322                throw new IllegalArgumentException("You must specify an application for 'connector' retrieval type");
323            }
324            if (retrievalApplication.supportsFeature(Application.Feature.NO_AGGREGATION)) {
325                throw new IllegalArgumentException("The application " + retrievalApplication.getName() + " does not support aggregation");
326            }
327            try {
328                Connector connector = ConnectorFactory.getConnector(retrievalApplication, null);
329                CloseableIterator<ResourceObject> objectIterator = connector.iterateObjects(retrievalApplication.getAccountSchema().getName(), null, new HashMap<>());
330                if (terminationRegistrar != null) {
331                    terminationRegistrar.accept((terminationContext) -> objectIterator.close());
332                }
333                return transformerConstructor.apply(new AbstractThreadedObjectIteratorTask.CloseableIteratorWrapper(objectIterator));
334            } catch(ConnectorException e) {
335                throw new GeneralException(e);
336            }
337        } else {
338            throw new IllegalStateException("You must specify a rule, script, filter, sql, file, connector, or list to retrieve target objects");
339        }
340        items.ignoreNulls();
341        return items;
342    }
343
344    /**
345     * Returns true if the retriever is configured with a retrieval class appropriate
346     * for the retrieval type, otherwise returns false.
347     * @return True if a retrieval class is present
348     */
349    public boolean hasRetrievalClass() {
350        if (getRetrievalType() == RetrievalType.file) {
351            return this.retrievalFileClass != null;
352        } else if (getRetrievalType() == RetrievalType.filter) {
353            return this.retrievalFilterClass != null;
354        } else if (getRetrievalType() == RetrievalType.sql) {
355            return this.retrievalSqlClass != null;
356        }
357        return false;
358    }
359
360    /**
361     * Gets the retrieval type
362     * @return The retrieval type
363     */
364    public RetrievalType getRetrievalType() {
365        return retrievalType;
366    }
367
368    /**
369     * Initializes this retriever from the input parameters
370     * @param context The context
371     * @param args The input arguments, usually from a Task
372     * @throws GeneralException if any Sailpoint-related failures occur
373     * @throws IllegalArgumentException if any arguments are incorrect or missing
374     */
375    @SuppressWarnings("unchecked")
376    private void initialize(SailPointContext context, Attributes<String, Object> args) throws GeneralException {
377        this.retrievalType = RetrievalType.valueOf(args.getString("retrievalType"));
378
379        if (retrievalType == RetrievalType.provided) {
380            List<String> values = args.getStringList("values");
381            if (values == null) {
382                throw new IllegalArgumentException("For input type = provided, a CSV in 'values' is required");
383            }
384
385            this.providedValues = values;
386        }
387
388        if (retrievalType == RetrievalType.sql) {
389            if (Util.isNullOrEmpty(args.getString("retrievalSql"))) {
390                throw new IllegalArgumentException("For input type = sql, a retrievalSql is required");
391            }
392
393            String className = args.getString("retrievalSqlClass");
394            if (Util.isNotNullOrEmpty(className)) {
395                this.retrievalSqlClass = (Class<? extends SailPointObject>) ObjectUtil.getSailPointClass(className);
396            }
397            this.retrievalSql = args.getString("retrievalSql");
398        }
399
400        if (retrievalType == RetrievalType.connector) {
401            String applicationName = args.getString("applicationName");
402            this.retrievalApplication = context.getObject(Application.class, applicationName);
403            if (this.retrievalApplication == null) {
404                throw new IllegalArgumentException("The application " + applicationName + " does not exist");
405            }
406        }
407
408        if (retrievalType == RetrievalType.file && Util.isNotNullOrEmpty(args.getString("retrievalFile"))) {
409            String filename = args.getString("retrievalFile");
410            this.retrievalFile = new File(filename);
411            this.retrievalFileDelimiter = args.getString("retrievalFileDelimiter");
412            String className = args.getString("retrievalFileClass");
413            if (Util.isNotNullOrEmpty(className)) {
414                this.retrievalFileClass = (Class<? extends SailPointObject>) ObjectUtil.getSailPointClass(className);
415            }
416            if (!this.retrievalFile.exists()) {
417                throw new IllegalArgumentException("Input file " + filename + " does not exist");
418            }
419            if (!this.retrievalFile.isFile()) {
420                throw new IllegalArgumentException("Input file " + filename + " does not appear to be a file (is it a directory?)");
421            }
422            if (!this.retrievalFile.canRead()) {
423                throw new IllegalArgumentException("Input file " + filename + " is not readable by the IIQ process");
424            }
425        }
426
427        if (retrievalType == RetrievalType.rule && Util.isNotNullOrEmpty(args.getString("retrievalRule"))) {
428            this.retrievalRule = context.getObjectByName(Rule.class, args.getString("retrievalRule"));
429        }
430
431        if (retrievalType == RetrievalType.script && Util.isNotNullOrEmpty(args.getString("retrievalScript"))) {
432            this.retrievalScript = new Script();
433            retrievalScript.setSource(args.getString("retrievalScript"));
434        }
435
436        if (retrievalType == RetrievalType.filter && Util.isNotNullOrEmpty(args.getString("retrievalFilter"))) {
437            this.retrievalFilter = Filter.compile(args.getString("retrievalFilter"));
438            String className = args.getString("retrievalFilterClass");
439            if (Util.isNotNullOrEmpty(className)) {
440                this.retrievalFilterClass = (Class<? extends SailPointObject>) ObjectUtil.getSailPointClass(className);
441            } else {
442                log.warn("No retrieval filter class given; assuming Identity");
443                this.retrievalFilterClass = Identity.class;
444            }
445        }
446    }
447
448    /**
449     * Sets the termination registrar object
450     * @param registar The termination registrar object
451     */
452    @Override
453    public void setTerminationRegistrar(Consumer<Functions.GenericCallback> registar) {
454        this.terminationRegistrar = registar;
455    }
456}