001package com.identityworksllc.iiq.common;
002
003import org.apache.commons.logging.Log;
004import org.apache.commons.logging.LogFactory;
005import sailpoint.Version;
006import sailpoint.api.SailPointContext;
007import sailpoint.object.SailPointObject;
008import sailpoint.plugin.PluginBaseHelper;
009import sailpoint.tools.GeneralException;
010import sailpoint.tools.Util;
011
012import java.lang.reflect.InvocationTargetException;
013import java.lang.reflect.Method;
014import java.sql.Connection;
015import java.sql.PreparedStatement;
016import java.sql.ResultSet;
017import java.sql.SQLException;
018import java.time.Instant;
019import java.util.Date;
020import java.util.Optional;
021
022/**
023 * In 8.4, a new class NamedTimestamp was added. We can't depend on it existing in
024 * earlier versions, but the behavior is so useful, we want to simulate it.
025 *
026 * In pre-8.4 versions, we will do so using a database table, iiqc_named_timestamps.
027 * In 8.4, we will simply use the object as-is via reflection.
028 */
029public class NamedTimestampUtils {
030    /**
031     * The SQL query to delete a timestamp by name.
032     */
033    public static final String DELETE_QUERY = "DELETE FROM iiqc_named_timestamps WHERE name = ?";
034
035    /**
036     * The SQL query to fetch a timestamp by name.
037     */
038    public static final String FETCH_VALUE_QUERY = "SELECT value FROM iiqc_named_timestamps WHERE name = ?";
039
040    /**
041     * The version string for IIQ 8.4.
042     */
043    public static final String IIQ_84 = "8.4";
044
045    /**
046     * The SQL query to insert a timestamp by name.
047     */
048    public static final String INSERT_QUERY = "INSERT INTO iiqc_named_timestamps (id, name, value) VALUES (?, ?, ?)";
049
050    /**
051     * The method names for the NamedTimestamp class.
052     */
053    public static final String METHOD_GET_TIMESTAMP = "getTimestamp";
054
055    /**
056     * The method names for the NamedTimestamp class.
057     */
058    public static final String METHOD_SET_TIMESTAMP = "setTimestamp";
059
060    /**
061     * The name of the NamedTimestamp object.
062     */
063    public static final String OBJECT_NAMED_TIMESTAMP = "sailpoint.object.NamedTimestamp";
064
065    /**
066     * The SQL query to check if the iiqc_named_timestamps table exists.
067     */
068    public static final String TABLE_EXISTS_QUERY = "SELECT 1 FROM iiqc_named_timestamps";
069
070    /**
071     * The SQL query to update a timestamp by name.
072     */
073    public static final String UPDATE_QUERY = "UPDATE iiqc_named_timestamps SET value = ? WHERE name = ?";
074
075    /**
076     * The SailPointContext to use.
077     */
078    private final SailPointContext context;
079
080    /**
081     * The logger to use.
082     */
083    private final Log log;
084
085    /**
086     * Creates a new NamedTimestampUtils instance.
087     * @param context the SailPointContext to use
088     */
089    public NamedTimestampUtils(SailPointContext context) {
090        this.context = context;
091        this.log = LogFactory.getLog(NamedTimestampUtils.class);
092    }
093
094    /**
095     * Fetches a timestamp by name. If the timestamp is not found, an empty Optional is returned.
096     * @param name The name of the timestamp
097     * @return An Optional containing the timestamp, or an empty Optional if the timestamp is not found
098     * @throws GeneralException if an error occurs during retrieval
099     */
100    private Optional<Instant> fetch84Timestamp(String name) throws GeneralException {
101        if (log.isTraceEnabled()) {
102            log.trace("Fetching 8.4 timestamp for " + name);
103        }
104        try {
105            @SuppressWarnings("unchecked")
106            Class<? extends SailPointObject> timestampClass = (Class<? extends SailPointObject>) Class.forName(OBJECT_NAMED_TIMESTAMP);
107
108            SailPointObject namedTimestamp = context.getObjectByName(timestampClass, name);
109
110            if (namedTimestamp != null) {
111                Method getter = timestampClass.getMethod(METHOD_GET_TIMESTAMP);
112                Date timestampValue = (Date) getter.invoke(namedTimestamp);
113
114                if (timestampValue != null) {
115                    return Optional.of(timestampValue.toInstant());
116                } else {
117                    log.warn("NamedTimestamp " + name + " did not have a timestamp value??");
118                }
119            } else {
120                log.info("NamedTimestamp " + name + " not found");
121            }
122        } catch(ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
123            log.error("Failure getting 8.4 named timestamp", e);
124            throw new GeneralException(e);
125        }
126
127        return Optional.empty();
128    }
129
130    /**
131     * Fetches a timestamp by name. If the timestamp is not found, an empty Optional is returned.
132     * @param name The name of the timestamp
133     * @return An Optional containing the timestamp, or an empty Optional if the timestamp is not found
134     * @throws GeneralException if an error occurs during retrieval
135     */
136    public Optional<Instant> get(String name) throws GeneralException {
137        if (Version.getFullVersion().contains(IIQ_84)) {
138            Optional<Instant> result = fetch84Timestamp(name);
139            if (!result.isPresent() && tableExists()) {
140                result = fetchTimestampFromDatabase(name);
141            }
142            return result;
143        } else if (tableExists()) {
144            return fetchTimestampFromDatabase(name);
145        } else {
146            throw new GeneralException("NamedTimestamp table does not exist and 8.4 timestamp class is not available");
147        }
148    }
149
150    /**
151     * Fetches a timestamp by name from the custom database table.
152     * @param name the name of the timestamp
153     * @return an Optional containing the timestamp, or an empty Optional if the timestamp is not found
154     * @throws GeneralException if an error occurs during retrieval
155     */
156    private Optional<Instant> fetchTimestampFromDatabase(String name) throws GeneralException {
157        if (log.isTraceEnabled()) {
158            log.trace("Fetching database timestamp for " + name);
159        }
160
161        try (Connection connection = PluginBaseHelper.getConnection()) {
162            try (PreparedStatement stmt = connection.prepareStatement(FETCH_VALUE_QUERY)) {
163                stmt.setString(1, name);
164
165                try (ResultSet results = stmt.executeQuery()) {
166                    if (results.next()) {
167                        return Optional.of(results.getTimestamp(1).toInstant());
168                    } else {
169                        log.info("NamedTimestamp " + name + " not found");
170                    }
171                }
172            }
173        } catch(SQLException e) {
174            throw new RuntimeException(e);
175        }
176
177        return Optional.empty();
178    }
179
180    /**
181     * Removes a timestamp from the database.
182     * @param name the name of the timestamp to remove
183     * @throws GeneralException if an error occurs during removal
184     */
185    private void removeTimestampFromDatabase(String name) throws GeneralException {
186        if (!tableExists()) {
187            return;
188        }
189
190        if (log.isTraceEnabled()) {
191            log.trace("Removing timestamp from database: " + name);
192        }
193
194        try (Connection connection = PluginBaseHelper.getConnection()) {
195            try (PreparedStatement stmt = connection.prepareStatement(DELETE_QUERY)) {
196                stmt.setString(1, name);
197                stmt.executeUpdate();
198            }
199        } catch(SQLException e) {
200            log.error("Error removing timestamp from database", e);
201            throw new GeneralException(e);
202        }
203
204    }
205
206    /**
207     * Attempts to store the timestamp using IIQ 8.4's NamedTimestamp object.
208     *
209     * @param name      the name of the timestamp
210     * @param timestamp the timestamp to store
211     * @throws GeneralException if an error occurs during storage
212     */
213    private void store84Timestamp(String name, Instant timestamp) throws GeneralException {
214        if (log.isTraceEnabled()) {
215            log.trace("Storing 8.4 timestamp: " + name + ", value = " + timestamp);
216        }
217
218        try {
219            @SuppressWarnings("unchecked")
220            Class<? extends SailPointObject> timestampClass = (Class<? extends SailPointObject>) Class.forName(OBJECT_NAMED_TIMESTAMP);
221
222            SailPointObject namedTimestamp = context.getObjectByName(timestampClass, name);
223
224            if (namedTimestamp == null) {
225                // Create a new NamedTimestamp object
226                namedTimestamp = timestampClass.getConstructor().newInstance();
227                namedTimestamp.setName(name);
228            }
229
230            // Set the timestamp
231            Method setTimestampMethod = timestampClass.getMethod(METHOD_SET_TIMESTAMP, Date.class);
232            setTimestampMethod.invoke(namedTimestamp, Date.from(timestamp));
233
234            // Save the object
235            context.saveObject(namedTimestamp);
236            context.commitTransaction();
237
238        } catch(ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException | InstantiationException e) {
239            log.error("Failure storing 8.4 named timestamp", e);
240        }
241    }
242
243    /**
244     * Stores a timestamp by name.
245     * @param name the name of the timestamp
246     * @param instant the timestamp to store
247     * @throws GeneralException if an error occurs during storage
248     */
249    public void put(String name, Instant instant) throws GeneralException {
250        if (Util.isNullOrEmpty(name)) {
251            throw new IllegalArgumentException("Name cannot be null or empty");
252        }
253
254        if (Version.getFullVersion().contains(IIQ_84)) {
255            store84Timestamp(name, instant);
256            removeTimestampFromDatabase(name);
257        } else if (tableExists()) {
258            storeTimestampInDatabase(name, instant);
259        } else {
260            throw new GeneralException("NamedTimestamp table does not exist and 8.4 timestamp class is not available");
261        }
262    }
263
264    /**
265     * Stores a timestamp by name.
266     *
267     * Equivalent to calling storeTimestamp(name, Instant.ofEpochMillis(value)).
268     *
269     * @param name the name of the timestamp
270     * @param value the timestamp to store
271     * @throws GeneralException if an error occurs during storage
272     */
273    public void put(String name, long value) throws GeneralException {
274        put(name, Instant.ofEpochMilli(value));
275    }
276
277    /**
278     * Stores a timestamp in the custom database table.
279     *
280     * @param name the name of the timestamp
281     * @param timestamp the timestamp to store
282     * @throws GeneralException if an error occurs during storage
283     */
284    private void storeTimestampInDatabase(String name, Instant timestamp) throws GeneralException {
285        if (log.isTraceEnabled()) {
286            log.trace("Storing timestamp in database: " + name + ", value = " + timestamp);
287        }
288        try (Connection connection = PluginBaseHelper.getConnection()) {
289            // Check if the timestamp already exists
290            boolean exists = false;
291            try (PreparedStatement stmt = connection.prepareStatement(FETCH_VALUE_QUERY)) {
292                stmt.setString(1, name);
293                try (ResultSet results = stmt.executeQuery()) {
294                    if (results.next()) {
295                        exists = true;
296                    }
297                }
298            }
299
300            if (exists) {
301                // Update existing timestamp
302                try (PreparedStatement updateStmt = connection.prepareStatement(
303                        UPDATE_QUERY)) {
304                    updateStmt.setTimestamp(1, java.sql.Timestamp.from(timestamp));
305                    updateStmt.setString(2, name);
306                    updateStmt.executeUpdate();
307                }
308            } else {
309                // Insert new timestamp
310                String newId = java.util.UUID.randomUUID().toString();
311                try (PreparedStatement insertStmt = connection.prepareStatement(
312                        INSERT_QUERY)) {
313                    insertStmt.setString(1, newId);
314                    insertStmt.setString(2, name);
315                    insertStmt.setTimestamp(3, java.sql.Timestamp.from(timestamp));
316                    insertStmt.executeUpdate();
317                }
318            }
319        } catch(SQLException e) {
320            log.error("Error storing timestamp in database", e);
321            throw new RuntimeException(e);
322        }
323    }
324
325    /**
326     * Checks if the custom database table exists.
327     * @return true if the table exists, false otherwise
328     * @throws GeneralException if an error occurs during the check
329     */
330    private boolean tableExists() throws GeneralException {
331        try (Connection connection = PluginBaseHelper.getConnection()) {
332            try (PreparedStatement stmt = connection.prepareStatement(TABLE_EXISTS_QUERY)) {
333                stmt.executeQuery();
334                return true;
335            }
336        } catch(SQLException e) {
337            if (log.isTraceEnabled()) {
338                log.trace("NamedTimestamp table does not exist", e);
339            }
340            return false;
341        }
342    }
343}