From ea23a2bad5ff2c63adf69d9f6ea3e63b74993836 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 18 Oct 2026 22:36:25 -0400 Subject: [PATCH] Audit logs stage 1: Admin > Audit Logs rebuilt on the standard list with search, sortable columns and paging, filters for date, who (agent, portal contact or ITFlow itself), client, type and action, and counted views for Changes, Deletions, Sign-ins, Security and Data out; each entry shows who with their avatar, the client, the type and action as a coloured label, the description without the name in front, the IP and device in words, and links to its record while it exists or says No longer here; a quick look with the full entry, the raw user agent, everything from that IP, the record's other entries and what the same person did 15 minutes either side; CSV/PDF export with the page's filters, view and picked columns, itself audited; new functions/audit_log.php, admin/modals/audit_log/audit_log.php and audit_log_export.php, admin/post/audit_log.php and audit_logs export columns; agent/files.php takes ?open= for a file's quick look; database 3.3.1 adds log_client_id and log_entity_id keys on logs, gives each action one name (Unarchive/Restored to Restore, Deleted, Merged, Closed, Reopened, Resolved and Sent to Delete, Merge, Close, Reopen, Resolve and Send, Client Access to Denied, saved card and payment provider Updates to Create or Delete, contract template Update to Edit, type client to Client) and repoints entries at the right record (reply edits and redactions to the ticket, API document edits to the document, reimbursements to the recording expense, API client restores to the client), guarded so it can run again; logAudit call sites in agent, admin, api, client, cron and functions use the same names and record ids --- CHANGELOG.md | 19 +- admin/audit_logs.php | 524 +++++++++--------- admin/database_updates/3.3.1.php | 68 +++ admin/modals/audit_log/audit_log.php | 196 +++++++ admin/modals/audit_log/audit_log_export.php | 150 +++++ admin/post/audit_log.php | 71 +++ admin/post/contract_template.php | 2 +- admin/post/saved_payment_method.php | 2 +- .../post/settings_online_payment_clients.php | 2 +- admin/post/sla.php | 2 +- admin/post/starter_content.php | 2 +- agent/files.php | 17 + agent/post/client.php | 6 +- agent/post/expense.php | 2 +- agent/post/quote.php | 4 +- agent/post/ticket.php | 12 +- api/v1/clients/unarchive.php | 2 +- api/v1/contacts/unarchive.php | 2 +- api/v1/credentials/unarchive.php | 2 +- api/v1/documents/unarchive.php | 2 +- api/v1/documents/update.php | 2 +- api/v1/domains/unarchive.php | 2 +- api/v1/locations/unarchive.php | 2 +- api/v1/networks/unarchive.php | 2 +- api/v1/software/unarchive.php | 2 +- api/v1/ticket_replies/create.php | 2 +- api/v1/tickets/close.php | 2 +- api/v1/tickets/resolve.php | 2 +- api/v1/vendors/unarchive.php | 2 +- client/post.php | 4 +- cron/nightly_tasks.php | 2 +- db.sql | 4 +- functions.php | 1 + functions/audit_log.php | 343 ++++++++++++ functions/auth.php | 4 +- functions/expenses.php | 2 +- functions/export.php | 16 + functions/ticket.php | 4 +- 38 files changed, 1169 insertions(+), 316 deletions(-) create mode 100644 admin/database_updates/3.3.1.php create mode 100644 admin/modals/audit_log/audit_log.php create mode 100644 admin/modals/audit_log/audit_log_export.php create mode 100644 admin/post/audit_log.php create mode 100644 functions/audit_log.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd553ae2..a0b1ab89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.0) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.1) in the same pass. Nothing else is required. -This is a big release, with fifty-one database steps in one go, so take a backup before you update. +This is a big release, with fifty-two database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -210,6 +210,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Deleting an asset, contact, client or location through the API now also cleans up whatever pointed at it, the same as deleting it in ITFlow. - New endpoint to read services, under the Support permission. Vendors take the account manager's phone and email. - A call the key's role doesn't allow is refused with what it needs and what the key's user has - "This needs Modify on Support - the key's user, Tess Tech (Technician), has Read." - in place of "The user linked to this API key does not have permission for this action." It is still a 403. Anything that matches on the old text needs changing. +- Audit log: each thing done now has one name, in the entries already there as well as new ones. Unarchive and Restored are Restore, Deleted is Delete, and Closed, Resolved, Reopened, Merged and Sent are Close, Resolve, Reopen, Merge and Send. An agent turned away from a client they can't open is Denied, where it was Access. A saved card or payment provider deleted is Delete and a card saved is Create, where both were Update, and a contract template changed is Edit. A report or script that reads the `logs` table by the old names needs the new ones. ### New Features & Updates @@ -928,6 +929,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Create on the Details tab before ticking that the key and password were copied opens the Key tab, rather than doing nothing. - Revoke ends a key today. Delete is for expired keys, as before, one at a time or several from the bulk menu. - The audit log says who a new key acts as and when it ends, and what an edit changed: "renamed from RMM, expiring 2027-06-01 (was 2026-12-01), acting as Tess Tech (was Johnny Test)". +- **Audit logs** (rebuilt) + - Admin > Audit Logs lists who did what, when and from where, with views for Changes, Deletions, Sign-ins, Security (failed and blocked sign-ins, an agent turned away from a client, viewing the portal as a contact, and changes to users, roles, API keys, the master key, settings and backups) and Data out (passwords looked at, exports, downloads and shared links), each counted for the filters you have picked. + - Search finds an entry by what it says, who did it, the client, the IP address or the browser, and it can be narrowed by the date, who did it - an agent, a client contact in the portal, or ITFlow itself for cron, the API and the email parser - the client, the type and, under More filters, the action. It sorts from the column headings, a page at a time. + - Each entry shows when, who with their picture, the client, the kind of record with the action as a coloured label, what it says, and the IP address with the system and browser in words. A script calling the API shows as itself, for example python-requests. + - An entry about a record links to it while it is still there - a client, contact, location, vendor, asset, network, rack, credential, certificate, domain, license, service, document, file, shared link, ticket, project, quote, invoice, recurring invoice, product, expense, recurring expense, trip or template - and says No longer here once it has been deleted. A credential, certificate, domain, file, expense or trip opens on its list with its quick look open. + - The quick look on each entry shows all of it: the whole description, who with their email, the IP address with everything else from it, the browser as it reported itself, the record's other entries with a link to all of them, and what the same person did in the quarter hour either side, each of those opening in turn. + - Export downloads what you are looking at, or whatever you pick in the window, as a CSV or PDF, with the columns you choose - who, the record by name and the device among them. Each export is itself in the audit log. + - Entries that pointed at the wrong record now point at the right one, old ones included: editing or redacting a ticket reply is on the ticket, a document changed through the API on the document, paying an agent back on the expense that recorded it, and a client restored through the API on the client. - **Emails** (rebuilt) - Ticket emails to clients now look like the billing emails: your logo or name, a coloured heading saying what it is, the ticket's number, subject, status and priority in a box, the reply or the details quoted as they were written with who wrote them, a button to open the ticket, and your details at the foot. This covers a ticket opened by an agent, on a schedule or from an email, a reply, resolved, closed, being made a watcher, replying to a closed ticket, a reply through the API and a task needing their approval. - Each says under it why they get it - the contact on the ticket, watching it, or asking for every update on their company's tickets - in place of the YOU ARE A COLLABORATOR block. @@ -1573,6 +1582,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Export and import: new `functions/software_template_transfer.php` - `softwareTemplatesExport($ids, $archived)` (the file's contents: `format` itflow-license-templates, `version` 1, `templates`, each with `name`, `version`, `description`, `type`, `license_type`, `notes`, `seats`, `term`, `vendor` - `name` and the `SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS`, or null - and `product` by name), `softwareTemplatesExportFileName()`, `softwareTemplatesImportRead($contents, &$error)` (checks and tidies a file, and takes the starter pack's array the same way), `softwareTemplateImportVendor($vendor, &$made)`, `softwareTemplateImportProduct($name)` (current products only), `softwareTemplatesImport($templates, $existing, $only_existing)` (`skip`, `copy` or `replace`; `added`, `replaced`, `skipped`, `vendors`, `products` not found, `ids`) and `softwareTemplatesImportSummary()`. Handlers in `admin/post/software_template.php`: POST `export_software_templates` (`software_template_ids[]`, `archived`), GET `export_software_template` and POST `import_software_templates` (`file`, `existing`), with `admin/modals/software_template/software_template_export.php` and `software_template_import.php`. Audit entries are Software Template Export and Import. - Starter Content: new `license_templates` pack, after `products` so a load of every pack links the products - `starterContentLicenseTemplates()` in the file format, `starterLoadLicenseTemplates()` through `softwareTemplatesImport()`, and `starterUpdateLicenseTemplates()` with the `update_starter_license_templates` handler. - Report: `agent/reports/license_templates.php` (`client_id` and the period; `export=csv` by template, `table=clients` by client; the billing columns only with `module_sales`), `license_templates` in `reportCatalog()` under Clients & Documentation. +- Audit logs: + - Database 3.3.1: `logs` keys `log_client_id` (`log_client_id`, `log_created_at`) and `log_entity_id` (`log_entity_id`, `log_type`). Existing entries are moved to one action name each (Unarchive and Restored to Restore, Deleted, Merged, Closed, Reopened, Resolved and Sent to Delete, Merge, Close, Reopen, Resolve and Send, Client Access to Denied, the Stripe and Payment Provider Updates that deleted or saved to Delete or Create, Contract Template Update to Edit) and `log_type` `client` to `Client`. Entries that pointed at the wrong record are moved to the right one - a ticket reply edit or redaction to its ticket, a document edited through the API from its version to the document, Expense Reimburse from the reimbursement to the expense recording it (Reimbursement Undo to none), and the API's client unarchive from a Contact with no record to the Client - only while the `log_entity_id` key, added last, isn't there, so the step can run again. + - The code logs the same names: `logAudit()` calls across `agent/post`, `admin/post`, `api/v1`, `cron/nightly_tasks.php` and `functions/ticket.php` now pass Restore, Delete, Merge, Close, Reopen, Resolve and Send; `enforceClientAccess()` logs Client Denied; editing and redacting a reply pass the ticket's id, `api/v1/documents/update.php` the document's, `expenseReimburse()` the expense recording the payment, and `api/v1/clients/unarchive.php` logs Client Restore on the client. The client portal's saved card logs Stripe Create and Delete, the saved payment method and online payment client handlers Delete, and contract templates Edit. + - New `functions/audit_log.php`: `auditLogKinds()` (by `log_type`: `icon`, and for a record its `table`, `id`, `name` - a column or expression - `client` and `url`, with `{id}`, `{client}` and `{client_q}`), `auditLogTypeIcon()`, `auditLogActionStyle()` (`tone`, `icon`), `auditLogActionPill()`, `auditLogViews()` (`label`, `icon`, `title`, `where`), `auditLogFiltersSql($src, &$summary)` (`q`, `client`, `user` - an id or `system` - `type`, `action`, `entity`, `view`, `dtf`, `dtt`), `auditLogJoinsSql()`, `auditLogRecords($rows)` (one query per kind), `auditLogRecordLink($row, $records, $label)`, `auditLogDescription($row)` (without the name in front), `auditLogWho($row)` (`name`, `kind` - agent, portal or system - `html`) and `auditLogDevice($user_agent)`. + - Admin: `admin/audit_logs.php` is rebuilt (`q`, `client`, `user`, `type`, `action`, `entity`, `view` - `changes`, `deletions`, `signins`, `security` or `data` - the date filter, `sort` - `log_created_at`, `user_name`, `client_name`, `log_type`, `log_action`, `log_ip` or `log_id` - `order` and `page`). New `admin/modals/audit_log/audit_log.php` (`id`) and `audit_log_export.php`, and `admin/post/audit_log.php` with `export_audit_logs` (the same filters, `columns[]` from the `audit_logs` export columns), written 500 rows at a time and audited as Audit Log Export. + - `agent/files.php` takes `open` (a file's id) to open its quick look, as the credential, certificate, domain, expense, recurring expense and trip lists do. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/audit_logs.php b/admin/audit_logs.php index 66e4baf94..2f0ce2c8c 100644 --- a/admin/audit_logs.php +++ b/admin/audit_logs.php @@ -1,295 +1,269 @@ Audit Logs - who did what, when and from where: every change, + * sign-in, look at a secret and export logAudit() writes. Searched, filtered + * by date, who, client, type and action, and in views - changes, deletions, + * sign-ins, security and data out - each counted for the filters picked. + * Each entry links to the record it is about while that still exists, and + * opens a quick look with all of it (modals/audit_log/audit_log.php). What is + * shown can be exported (export_audit_logs). The work is in + * functions/audit_log.php. + */ + // Default Column Sortby Filter $sort = "log_id"; $order = "DESC"; require_once "includes/inc_all_admin.php"; -// User Filter -if (isset($_GET['user']) & !empty($_GET['user'])) { - $user_query = 'AND (log_user_id = ' . intval($_GET['user']) . ')'; - $user_filter = intval($_GET['user']); -} else { - // Default - any - $user_query = ''; - $user_filter = ''; +// Tab title - the file is named for the table, the page for what it holds +$page_title = 'Audit Logs'; + +// Only these sort - an unknown column would take the page down +$sortable_columns = [ + 'log_id' => "log_id $order", + 'log_created_at' => "log_created_at $order, log_id $order", + 'user_name' => "user_name IS NULL, user_name $order, log_id DESC", + 'client_name' => "client_name IS NULL, client_name $order, log_id DESC", + 'log_type' => "log_type $order, log_action $order, log_id DESC", + 'log_action' => "log_action $order, log_id DESC", + 'log_ip' => "log_ip IS NULL, log_ip $order, log_id DESC", +]; +if (!isset($sortable_columns[$sort])) { + $sort = 'log_id'; } -// Client Filter -if (isset($_GET['client']) & !empty($_GET['client'])) { - $client_query = 'AND (log_client_id = ' . intval($_GET['client']) . ')'; - $client_filter = intval($_GET['client']); -} else { - // Default - any - $client_query = ''; - $client_filter = ''; -} - -// Log Type Filter -if (isset($_GET['type']) & !empty($_GET['type'])) { - $log_type_query = "AND (log_type = '" . escapeSql($_GET['type']) . "')"; - $type_filter = escapeHtml($_GET['type']); -} else { - // Default - any - $log_type_query = ''; - $type_filter = ''; -} - -// Log Action Filter -if (isset($_GET['action']) & !empty($_GET['action'])) { - $log_action_query = "AND (log_action = '" . escapeSql($_GET['action']) . "')"; - $action_filter = escapeHtml($_GET['action']); -} else { - // Default - any - $log_action_query = ''; - $action_filter = ''; -} - -/* - * The date filter is a half-open range rather than DATE(log_created_at) BETWEEN - * ... - wrapping the column in a function makes the comparison non-sargable, so - * KEY log_created_at could never be used and every page view scanned the whole - * table. '< dtt + 1 day' keeps the end date inclusive, which BETWEEN on a bare - * DATETIME would not: '2026-08-28' alone means midnight, and would drop that - * day's rows. - */ -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS client_id, client_name, log_action, log_created_at, log_description, log_entity_id, log_id, - log_ip, log_type, log_user_agent, user_id, user_name FROM logs - LEFT JOIN users ON log_user_id = user_id - LEFT JOIN clients ON log_client_id = client_id - WHERE (log_type LIKE '%$q%' OR log_action LIKE '%$q%' OR log_description LIKE '%$q%' OR log_ip LIKE '%$q%' OR log_user_agent LIKE '%$q%' OR user_name LIKE '%$q%' OR client_name LIKE '%$q%') - AND log_created_at >= '$dtf 00:00:00' AND log_created_at < DATE_ADD('$dtt', INTERVAL 1 DAY) - $user_query - $client_query - $log_type_query - $log_action_query - ORDER BY $sort $order LIMIT $record_from, $record_to" -); +$views = auditLogViews(); +$view = isset($_GET['view']) && is_string($_GET['view']) && isset($views[$_GET['view']]) ? $_GET['view'] : ''; +$filter_src = array_merge($_GET, ['dtf' => $dtf, 'dtt' => $dtt, 'view' => $view]); +$joins = auditLogJoinsSql(); +$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS log_id, log_type, log_action, log_description, log_ip, log_user_agent, log_created_at, log_client_id, log_user_id, log_entity_id, + user_name, user_avatar, user_type, client_name + FROM logs $joins + WHERE " . auditLogFiltersSql($filter_src) . " + ORDER BY {$sortable_columns[$sort]} + LIMIT $record_from, $record_to"); $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$rows = []; +while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row; +} +$records = auditLogRecords($rows); +$kinds = auditLogKinds(); + +// Each view counted for the rest of the filters +$count_sql = []; +foreach ($views as $definition) { + $count_sql[] = 'COALESCE(SUM(' . ($definition['where'] === '' ? '1' : $definition['where']) . '), 0)'; +} +$counts = array_combine(array_keys($views), array_map('intval', mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " + FROM logs $joins WHERE " . auditLogFiltersSql(array_merge($filter_src, ['view' => ''])))))); + +$client_filter = is_scalar($_GET['client'] ?? null) ? intval($_GET['client']) : 0; +$user_filter = is_string($_GET['user'] ?? null) ? $_GET['user'] : ''; +$type_filter = is_string($_GET['type'] ?? null) ? $_GET['type'] : ''; +$action_filter = is_string($_GET['action'] ?? null) ? $_GET['action'] : ''; +$entity_filter = is_scalar($_GET['entity'] ?? null) ? intval($_GET['entity']) : 0; +$more_filters = $action_filter !== '' || $entity_filter; +$any_filter = $q !== '' || $client_filter || $user_filter !== '' || $type_filter !== '' || $action_filter !== '' || $entity_filter || $dtf !== '1970-01-01' || $dtt !== '2099-12-31'; + +$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) { + return '' + . $label . ($sort === $column ? " $order_icon" : '') . ''; +}; +// A view keeps the filters, and starts from its first page +$view_url = function ($key) { + $query = $_GET; + unset($query['page']); + $query['view'] = $key; + return '?' . http_build_query(array_filter($query, function ($value) { + return $value !== '' && $value !== null && $value !== []; + })); +}; + +// When a view has nothing in it, and there are no filters to blame +$view_empty = [ + 'changes' => 'No changes yet', + 'deletions' => 'Nothing deleted yet', + 'signins' => 'No sign-ins yet', + 'security' => 'Nothing to do with security yet', + 'data' => 'Nothing looked at or taken out yet', +]; + ?> -
-
-

Audit Logs

+
+
+

Audit Logs

+
+
-
-
-
-
-
- - - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
" id="advancedFilter"> -
-
-
- - - - - -
-
-
-
-
-
-
- - text-nowrap"> - - - - - - - - - - - - - - - $client_name"; - } - $log_entity_id = intval($row['log_entity_id']); - - ?> - - - - - - - - - - - - - - - - - -
- - Timestamp - - - - User - - - - Client - - - - Type - - - - Action - - - - Description - - - - IP Address - - - - User Agent - -
$log_user_browser" ?>
-
- 'audit logs', - 'icon' => 'fa-history', - 'text' => 'Who changed what, and when, shows here.', - ]; - ?> -
+
+
+ +
+
+
+ + + +
+
+
+ + + + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ + +
+
+ +
+ + Just one record: # + Every record +
+ +
+
+
+ +
+
+ + "> + + + + + + + + + + + + 0 && isset($kinds[$type]['table']); + $device = auditLogDevice($row['log_user_agent']); + $from = implode(' · ', array_filter([(string) $row['log_ip'], $device])); + ?> + + + + + + + + + + + +
Description
+
+
+
+ + + + - + + +
+ +
+
+ +
+ +
No longer here
+ + +
+ +
+
+
+
+ +
+
+ 'audit log entries', + 'icon' => 'fa-history', + 'filtered' => $any_filter, + 'views' => ['view'], + 'view' => $view !== '' ? ['icon' => $views[$view]['icon'], 'title' => $view_empty[$view], 'text' => escapeHtml($views[$view]['title']) . ' show here.'] : null, + 'text' => 'Who changed what, signed in, looked at a password or exported something - when, and from where - shows here.', + ]; + require_once "../includes/filter_footer.php"; + ?> +
'Restore', 'Restored' => 'Restore', 'Deleted' => 'Delete', 'Merged' => 'Merge', 'Closed' => 'Close', + 'Reopened' => 'Reopen', 'Resolved' => 'Resolve', 'Sent' => 'Send'] as $itflow_old_action => $itflow_new_action) { + mysqli_query($mysqli, "UPDATE `logs` SET `log_action` = '$itflow_new_action' WHERE `log_action` = '$itflow_old_action'"); + } + mysqli_query($mysqli, "UPDATE `logs` SET `log_action` = 'Denied' WHERE `log_type` = 'Client' AND `log_action` = 'Access'"); + mysqli_query($mysqli, "UPDATE `logs` SET `log_action` = 'Delete' WHERE `log_type` IN ('Payment Provider', 'Stripe') AND `log_action` = 'Update' AND `log_description` LIKE '% deleted %'"); + mysqli_query($mysqli, "UPDATE `logs` SET `log_action` = 'Create' WHERE `log_type` = 'Stripe' AND `log_action` = 'Update' AND `log_description` LIKE '% saved payment method %'"); + mysqli_query($mysqli, "UPDATE `logs` SET `log_action` = 'Edit' WHERE `log_type` = 'Contract Template' AND `log_action` = 'Update'"); + + // 3. Two indexes on logs - by client, for the client filter and a client's + // recent activity, and by record, for a record's history. logs takes a + // write per change, so these two and no more; the record's id leads + // because it is the selective column, its type trails. + $itflow_indexes = [ + ['logs', 'log_client_id', ['log_client_id', 'log_created_at']], + ['logs', 'log_entity_id', ['log_entity_id', 'log_type']], + ]; + foreach ($itflow_indexes as $itflow_index) { + list($itflow_index_table, $itflow_index_name, $itflow_index_columns) = $itflow_index; + // No ADD INDEX IF NOT EXISTS - check first, so the step can run again + $itflow_index_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$itflow_index_table' AND INDEX_NAME = '$itflow_index_name' LIMIT 1"); + if ($itflow_index_exists && mysqli_num_rows($itflow_index_exists) > 0) { + continue; + } + mysqli_query($mysqli, "ALTER TABLE `$itflow_index_table` ADD KEY `$itflow_index_name` (`" . implode('`, `', $itflow_index_columns) . "`)"); + } + unset($itflow_logs_done, $itflow_indexes, $itflow_index, $itflow_index_table, $itflow_index_name, $itflow_index_columns, $itflow_index_exists, $itflow_old_action, $itflow_new_action); diff --git a/admin/modals/audit_log/audit_log.php b/admin/modals/audit_log/audit_log.php new file mode 100644 index 000000000..94b9326ee --- /dev/null +++ b/admin/modals/audit_log/audit_log.php @@ -0,0 +1,196 @@ + Audit Logs. + */ + +require_once '../../includes/modal_header.php'; + +$log_id = intval($_GET['id'] ?? 0); +$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT logs.*, user_name, user_email, user_avatar, user_type, client_name + FROM logs " . auditLogJoinsSql() . " WHERE log_id = $log_id")); +if (!$row) { + echo json_encode(['error' => 'That audit log entry no longer exists.']); + exit; +} + +$type = (string) $row['log_type']; +$type_sql = escapeSql($type); +$entity_id = intval($row['log_entity_id']); +$user_id = intval($row['log_user_id']); +$kinds = auditLogKinds(); +$who = auditLogWho($row); +$style = auditLogActionStyle($row['log_action']); +$records = auditLogRecords([$row]); +$link = auditLogRecordLink($row, $records); +$device = auditLogDevice($row['log_user_agent']); +$created = strtotime($row['log_created_at']); +$joins = auditLogJoinsSql(); + +// The record's other entries - its history here (an entry about no one record - a sign-in, a setting - has none) +$is_record = $entity_id > 0 && isset($kinds[$type]['table']); +$history = []; +$history_count = 0; +if ($is_record) { + $history_where = "log_entity_id = $entity_id AND log_type = '$type_sql' AND log_id <> $log_id"; + $history_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $history_where"))[0]); + $sql = mysqli_query($mysqli, "SELECT log_id, log_type, log_action, log_description, log_created_at, log_user_id, user_name, user_avatar, user_type + FROM logs $joins WHERE $history_where ORDER BY log_created_at DESC, log_id DESC LIMIT 8"); + while ($history_row = mysqli_fetch_assoc($sql)) { + $history[] = $history_row; + } +} + +// What the same person did around then - a quarter hour either side +$around = []; +$around_count = 0; +if ($user_id > 0) { + $around_where = "log_user_id = $user_id AND log_id <> $log_id + AND log_created_at >= '" . escapeSql($row['log_created_at']) . "' - INTERVAL 15 MINUTE AND log_created_at <= '" . escapeSql($row['log_created_at']) . "' + INTERVAL 15 MINUTE"; + $around_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $around_where"))[0]); + $sql = mysqli_query($mysqli, "SELECT log_id, log_type, log_action, log_description, log_created_at, log_entity_id, log_user_id, user_name, user_avatar, user_type + FROM logs $joins WHERE $around_where ORDER BY log_created_at ASC, log_id ASC LIMIT 10"); + while ($around_row = mysqli_fetch_assoc($sql)) { + $around[] = $around_row; + } +} + +// One line of a list of entries - when, the action and what it says; each opens its own quick look +$entry_line = function ($entry, $show_when_as = 'date') { + $time = strtotime($entry['log_created_at']); + $when = $show_when_as === 'time' ? date('g:i A', $time) : date('M j, Y g:i A', $time); + return '' + . '' . escapeHtml($when) . '' + . '' . auditLogActionPill($entry['log_action']) . '' + . '' . escapeHtml($entry['log_description']) . '' + . ''; +}; + +ob_start(); + +?> + + + + + + + + + + + + + +
+ + + + + +
+ + 0) { + + guardExportPdfRowCount($format, $num_rows); + + $export = beginExport('audit_logs', $format, "$session_company_name-Audit-Logs", 'Audit Logs', summarizeExportFilters($filter_summary)); + + // Written 500 at a time - each lot's records looked up together, one query per kind + $sql = mysqli_query($mysqli, "SELECT log_id, log_type, log_action, log_description, log_ip, log_user_agent, log_created_at, log_client_id, log_user_id, log_entity_id, + user_name, user_email, user_avatar, user_type, client_name + FROM logs $joins + WHERE $where + ORDER BY log_created_at DESC, log_id DESC"); + $write = function (array $chunk) use (&$export) { + $records = auditLogRecords($chunk); + foreach ($chunk as $row) { + $who = auditLogWho($row); + $row['audit_who'] = $who['name'] . ($who['kind'] === 'portal' ? ' (portal)' : ''); + $row['audit_record'] = $records[$row['log_type'] . '|' . intval($row['log_entity_id'])]['name'] ?? ''; + $row['audit_device'] = auditLogDevice($row['log_user_agent']); + addExportRow($export, $row); + } + }; + $chunk = []; + while ($row = mysqli_fetch_assoc($sql)) { + $chunk[] = $row; + if (count($chunk) === 500) { + $write($chunk); + $chunk = []; + } + } + if ($chunk) { + $write($chunk); + } + + finishExport($export); + + logAudit("Audit Log", "Export", "$session_name exported $num_rows audit log " . ($num_rows === 1 ? 'entry' : 'entries') . " to a " . strtoupper($format) . " file"); + + exit; + } + + flashAlert('No audit log entries match - nothing to export.', 'error'); + + redirect(); + +} diff --git a/admin/post/contract_template.php b/admin/post/contract_template.php index b2995c479..4c632b2c0 100644 --- a/admin/post/contract_template.php +++ b/admin/post/contract_template.php @@ -103,7 +103,7 @@ if (isset($_POST['edit_contract_template'])) { "); // Log action - logAudit("Contract Template", "Update", "$session_name updated contract template $name", 0, $contract_template_id); + logAudit("Contract Template", "Edit", "$session_name updated contract template $name", 0, $contract_template_id); // Flash + redirect flashAlert("Contract Template $name updated"); diff --git a/admin/post/saved_payment_method.php b/admin/post/saved_payment_method.php index bad78f3ca..87f145ee9 100644 --- a/admin/post/saved_payment_method.php +++ b/admin/post/saved_payment_method.php @@ -62,7 +62,7 @@ if (isset($_GET['delete_saved_payment'])) { // SQL Cascade delete will Remove All Associated Auto Payment Methods on recurring invoices in the recurring payments table. $payment_method_esc = escapeSql($payment_method); - logAudit("Payment Provider", "Update", "$session_name deleted saved payment method $saved_payment_description (PM: $payment_method_esc)", $client_id); + logAudit("Payment Provider", "Delete", "$session_name deleted saved payment method $saved_payment_description (PM: $payment_method_esc)", $client_id); flashAlert("Payment method $saved_payment_description removed", 'error'); diff --git a/admin/post/settings_online_payment_clients.php b/admin/post/settings_online_payment_clients.php index b4d7b0a03..cca2a54bc 100644 --- a/admin/post/settings_online_payment_clients.php +++ b/admin/post/settings_online_payment_clients.php @@ -39,7 +39,7 @@ if (isset($_GET['stripe_remove_pm'])) { mysqli_query($mysqli, "DELETE FROM recurring_payments WHERE recurring_payment_method = 'Stripe' AND recurring_payment_recurring_invoice_id = $recurring_invoice_id"); } - logAudit("Stripe", "Update", "$session_name deleted saved Stripe payment method (PM: $payment_method)", $client_id); + logAudit("Stripe", "Delete", "$session_name deleted saved Stripe payment method (PM: $payment_method)", $client_id); flashAlert("Payment method removed", 'error'); diff --git a/admin/post/sla.php b/admin/post/sla.php index 680060100..3690e4a02 100644 --- a/admin/post/sla.php +++ b/admin/post/sla.php @@ -78,7 +78,7 @@ if (isset($_GET['unarchive_sla'])) { mysqli_query($mysqli, "UPDATE slas SET sla_archived_at = NULL WHERE sla_id = $sla_id"); - logAudit("SLA", "Unarchive", "$session_name restored SLA ID $sla_id"); + logAudit("SLA", "Restore", "$session_name restored SLA ID $sla_id"); flashAlert("SLA restored"); diff --git a/admin/post/starter_content.php b/admin/post/starter_content.php index 4da838d53..6e8ea7b7c 100644 --- a/admin/post/starter_content.php +++ b/admin/post/starter_content.php @@ -163,7 +163,7 @@ if (isset($_POST['remove_demo_data'])) { $clients_removed = demoDataRemove($mysqli); if ($clients_removed) { - logAudit("Demo Data", "Deleted", "$session_name removed demo data - $clients_removed demo clients and all associated records"); + logAudit("Demo Data", "Delete", "$session_name removed demo data - $clients_removed demo clients and all associated records"); flashAlert("Removed $clients_removed demo clients and everything associated with them", 'error'); } else { flashAlert("No demo clients found - nothing to remove", 'info'); diff --git a/agent/files.php b/agent/files.php index 456480c48..1602529f6 100644 --- a/agent/files.php +++ b/agent/files.php @@ -17,6 +17,8 @@ * client should have a document from, and has none, are listed across the * top, each a click from making one. * + * ?open= opens a file's quick look - the audit log links to a file with it. + * * Open to anyone with access to the client (contracts and the like live here), * changes need support write access, like the handlers behind them. */ @@ -738,6 +740,21 @@ $preview = []; // the grid's previewer payload
+ + + diff --git a/agent/post/client.php b/agent/post/client.php index 07a741975..79e4c902a 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -222,7 +222,7 @@ if (isset($_GET['restore_client'])) { mysqli_query($mysqli, "UPDATE clients SET client_archived_at = NULL WHERE client_id = $client_id"); - logAudit("Client", "Restored", "$session_name restored client $client_name", $client_id); + logAudit("Client", "Restore", "$session_name restored client $client_name", $client_id); flashAlert("Client " . stripslashes($client_name) . " restored"); @@ -342,7 +342,7 @@ if (isset($_GET['delete_client'])) { // Don't leave the company's own-client designation pointing at a client that is gone mysqli_query($mysqli, "UPDATE settings SET config_internal_client_id = 0 WHERE config_internal_client_id = $client_id"); - logAudit("Client", "Deleted", "$session_name deleted Client $client_name and all associated data"); + logAudit("Client", "Delete", "$session_name deleted Client $client_name and all associated data"); flashAlert("Client " . stripslashes($client_name) . " deleted, with everything that was theirs", 'error'); @@ -1088,7 +1088,7 @@ if (isset($_POST['bulk_unarchive_clients'])) { mysqli_query($mysqli,"UPDATE clients SET client_archived_at = NULL WHERE client_id = $client_id"); - logAudit("client", "Restore", "$session_name restored $client_name", $client_id); + logAudit("Client", "Restore", "$session_name restored $client_name", $client_id); } diff --git a/agent/post/expense.php b/agent/post/expense.php index 8afc60f7f..9c17a7309 100644 --- a/agent/post/expense.php +++ b/agent/post/expense.php @@ -330,7 +330,7 @@ if (isset($_GET['expense_reimbursement_delete'])) { } $what = billingMoney($row['reimbursement_amount'], $row['reimbursement_currency_code']) . ' paid back to ' . $row['user_name'] . ' on ' . $row['reimbursement_date']; - logAudit("Expense", "Reimbursement Undo", "$session_name undid the " . escapeSql($what), 0, intval($row['reimbursement_id'])); + logAudit("Expense", "Reimbursement Undo", "$session_name undid the " . escapeSql($what)); flashAlert('Undone: ' . escapeHtml($what) . ' - its expenses are to reimburse again', 'error'); diff --git a/agent/post/quote.php b/agent/post/quote.php index 3aae25390..001b3120b 100644 --- a/agent/post/quote.php +++ b/agent/post/quote.php @@ -292,7 +292,7 @@ if (isset($_POST['mark_quote_sent'])) { logHistory('Sent', $history_description, 0, $quote_id); - logAudit("Quote", "Sent", "$session_name marked quote $quote_prefix$quote_number as sent - $sent_method", $client_id, $quote_id); + logAudit("Quote", "Send", "$session_name marked quote $quote_prefix$quote_number as sent - $sent_method", $client_id, $quote_id); flashAlert("Quote marked sent"); @@ -439,7 +439,7 @@ if (isset($_GET['mark_quote_invoiced'])) { mysqli_query($mysqli,"INSERT INTO history SET history_status = 'Invoiced', history_description = 'Quote marked as invoiced', history_quote_id = $quote_id"); - logAudit("Quote", "Sent", "$session_name marked quote $quote_prefix$quote_number as invoiced", $client_id, $quote_id); + logAudit("Quote", "Send", "$session_name marked quote $quote_prefix$quote_number as invoiced", $client_id, $quote_id); flashAlert("Quote marked invoiced"); diff --git a/agent/post/ticket.php b/agent/post/ticket.php index d48474adc..8d9c52929 100644 --- a/agent/post/ticket.php +++ b/agent/post/ticket.php @@ -60,7 +60,7 @@ function ticketMergeInto($child, $parent, $comment, $move_replies = false) { logTicketHistory($ticket_id, "$session_name merged this ticket into $parent_reference and closed it"); logTicketHistory($parent_id, "$session_name merged $child_reference into this ticket"); - logAudit("Ticket", "Merged", "$session_name merged ticket $child_reference into $parent_reference", intval($child['ticket_client_id']), $ticket_id); + logAudit("Ticket", "Merge", "$session_name merged ticket $child_reference into $parent_reference", intval($child['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_merge', $ticket_id); } @@ -897,7 +897,7 @@ if (isset($_POST['bulk_ticket_reply'])) { mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); setTicketResolutionSlaMet($ticket_id); logTicketHistory($ticket_id, "$session_name resolved the ticket"); - logAudit("Ticket", "Resolved", "$session_name resolved ticket $reference", intval($row['ticket_client_id']), $ticket_id); + logAudit("Ticket", "Resolve", "$session_name resolved ticket $reference", intval($row['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_resolve', $ticket_id); } @@ -1092,7 +1092,7 @@ if (isset($_POST['add_ticket_reply'])) { mysqli_query($mysqli, "UPDATE tickets SET ticket_resolved_at = NOW() WHERE ticket_id = $ticket_id"); setTicketResolutionSlaMet($ticket_id); logTicketHistory($ticket_id, "$session_name resolved the ticket"); - logAudit("Ticket", "Resolved", "$session_name resolved ticket $reference", $client_id, $ticket_id); + logAudit("Ticket", "Resolve", "$session_name resolved ticket $reference", $client_id, $ticket_id); triggerCustomAction('ticket_resolve', $ticket_id); } @@ -1232,7 +1232,7 @@ if (isset($_POST['edit_ticket_reply'])) { mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply = '$ticket_reply', ticket_reply_type = '$ticket_reply_type', ticket_reply_time_worked = '$ticket_reply_time_worked' WHERE ticket_reply_id = $ticket_reply_id"); - logAudit("Ticket", "Reply", "$session_name edited a reply on ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_reply_id); + logAudit("Ticket", "Reply", "$session_name edited a reply on ticket " . escapeSql(ticketReference($row)), $client_id, intval($row['ticket_id'])); flashAlert("Ticket reply updated"); @@ -1250,7 +1250,7 @@ if (isset($_POST['redact_ticket_reply'])) { mysqli_query($mysqli, "UPDATE ticket_replies SET ticket_reply = '$ticket_reply' WHERE ticket_reply_id = $ticket_reply_id"); - logAudit("Ticket", "Reply", "$session_name redacted a reply on ticket " . escapeSql(ticketReference($row)), $client_id, $ticket_reply_id); + logAudit("Ticket", "Reply", "$session_name redacted a reply on ticket " . escapeSql(ticketReference($row)), $client_id, intval($row['ticket_id'])); flashAlert("Ticket reply redacted"); @@ -1426,7 +1426,7 @@ if (isset($_GET['close_ticket'])) { logTicketHistory($ticket_id, "$session_name closed the ticket"); - logAudit("Ticket", "Closed", "$session_name closed ticket $reference", $client_id, $ticket_id); + logAudit("Ticket", "Close", "$session_name closed ticket $reference", $client_id, $ticket_id); triggerCustomAction('ticket_close', $ticket_id); diff --git a/api/v1/clients/unarchive.php b/api/v1/clients/unarchive.php index e531cfd3f..03c097e57 100644 --- a/api/v1/clients/unarchive.php +++ b/api/v1/clients/unarchive.php @@ -27,7 +27,7 @@ if (!empty($client_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Contact", "Unarchive", "$client_name unarchived via API ($api_key_name)", $client_id); + logAudit("Client", "Restore", "$client_name unarchived via API ($api_key_name)", $client_id, $client_id); } } } diff --git a/api/v1/contacts/unarchive.php b/api/v1/contacts/unarchive.php index 2fbe65390..1b179b21e 100644 --- a/api/v1/contacts/unarchive.php +++ b/api/v1/contacts/unarchive.php @@ -37,7 +37,7 @@ if (!empty($contact_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Contact", "Unarchive", "$contact_name unarchived via API ($api_key_name)", $client_id, $contact_id); + logAudit("Contact", "Restore", "$contact_name unarchived via API ($api_key_name)", $client_id, $contact_id); } } } diff --git a/api/v1/credentials/unarchive.php b/api/v1/credentials/unarchive.php index edb98eaa9..ca30fa2bf 100644 --- a/api/v1/credentials/unarchive.php +++ b/api/v1/credentials/unarchive.php @@ -33,7 +33,7 @@ if (!empty($credential_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Credential", "Unarchive", "$credential_name unarchived via API ($api_key_name)", $client_id, $credential_id); + logAudit("Credential", "Restore", "$credential_name unarchived via API ($api_key_name)", $client_id, $credential_id); } } } diff --git a/api/v1/documents/unarchive.php b/api/v1/documents/unarchive.php index e8d9645da..ab3909c8f 100644 --- a/api/v1/documents/unarchive.php +++ b/api/v1/documents/unarchive.php @@ -33,7 +33,7 @@ if (!empty($document_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Document", "Unarchive", "$document_name unarchived via API ($api_key_name)", $client_id, $document_id); + logAudit("Document", "Restore", "$document_name unarchived via API ($api_key_name)", $client_id, $document_id); logAudit("API", "Success", "Unarchived document $document_name via API ($api_key_name)", $client_id); } } diff --git a/api/v1/documents/update.php b/api/v1/documents/update.php index 8c845e8a4..a2b3e5b75 100644 --- a/api/v1/documents/update.php +++ b/api/v1/documents/update.php @@ -119,7 +119,7 @@ if (!empty($document_id)) { } // Logging - logAudit("Document", "Edit", "$name_db via API ($api_key_name), previous version kept", $client_id, $document_version_id); + logAudit("Document", "Edit", "$name_db via API ($api_key_name), previous version kept", $client_id, $document_id); logAudit("API", "Success", "Edited document $name_db via API ($api_key_name)", $client_id); } else { diff --git a/api/v1/domains/unarchive.php b/api/v1/domains/unarchive.php index 180f6a1f5..c251f6350 100644 --- a/api/v1/domains/unarchive.php +++ b/api/v1/domains/unarchive.php @@ -15,7 +15,7 @@ if (!empty($domain_id)) { if ($update_sql) { $update_count = mysqli_affected_rows($mysqli); - logAudit("Domain", "Unarchive", "$domain_name unarchived via API ($api_key_name)", $client_id, $domain_id); + logAudit("Domain", "Restore", "$domain_name unarchived via API ($api_key_name)", $client_id, $domain_id); } } } diff --git a/api/v1/locations/unarchive.php b/api/v1/locations/unarchive.php index aa89b8c64..076698113 100644 --- a/api/v1/locations/unarchive.php +++ b/api/v1/locations/unarchive.php @@ -33,7 +33,7 @@ if (!empty($location_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Location", "Unarchive", "$location_name unarchived via API ($api_key_name)", $client_id, $location_id); + logAudit("Location", "Restore", "$location_name unarchived via API ($api_key_name)", $client_id, $location_id); logAudit("API", "Success", "Unarchived location $location_name via API ($api_key_name)", $client_id); } } diff --git a/api/v1/networks/unarchive.php b/api/v1/networks/unarchive.php index 11d1fccfd..c4fe8e542 100644 --- a/api/v1/networks/unarchive.php +++ b/api/v1/networks/unarchive.php @@ -33,7 +33,7 @@ if (!empty($network_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Network", "Unarchive", "$network_name unarchived via API ($api_key_name)", $client_id, $network_id); + logAudit("Network", "Restore", "$network_name unarchived via API ($api_key_name)", $client_id, $network_id); logAudit("API", "Success", "Unarchived network $network_name via API ($api_key_name)", $client_id); } } diff --git a/api/v1/software/unarchive.php b/api/v1/software/unarchive.php index c32c568d8..b1a154cad 100644 --- a/api/v1/software/unarchive.php +++ b/api/v1/software/unarchive.php @@ -33,7 +33,7 @@ if (!empty($software_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Software", "Unarchive", "$software_name unarchived via API ($api_key_name)", $client_id, $software_id); + logAudit("Software", "Restore", "$software_name unarchived via API ($api_key_name)", $client_id, $software_id); } } } diff --git a/api/v1/ticket_replies/create.php b/api/v1/ticket_replies/create.php index 9754658f6..96307bd7f 100644 --- a/api/v1/ticket_replies/create.php +++ b/api/v1/ticket_replies/create.php @@ -102,7 +102,7 @@ if (!empty($ticket_id) && !empty($reply)) { logTicketHistory($ticket_id, "Resolved via the API ($api_key_name)"); - logAudit("Ticket", "Resolved", "Resolved ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id, $ticket_id); + logAudit("Ticket", "Resolve", "Resolved ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id, $ticket_id); triggerCustomAction('ticket_resolve', $ticket_id); } diff --git a/api/v1/tickets/close.php b/api/v1/tickets/close.php index 7052bba5e..cbbb72281 100644 --- a/api/v1/tickets/close.php +++ b/api/v1/tickets/close.php @@ -38,7 +38,7 @@ if (!empty($ticket_id)) { // Logging logTicketHistory($ticket_id, "Closed via the API ($api_key_name)"); - logAudit("Ticket", "Closed", "$ticket_prefix$ticket_number ticket via API ($api_key_name)", $client_id, $ticket_id); + logAudit("Ticket", "Close", "$ticket_prefix$ticket_number ticket via API ($api_key_name)", $client_id, $ticket_id); logAudit("API", "Success", "Closed ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id); } diff --git a/api/v1/tickets/resolve.php b/api/v1/tickets/resolve.php index a7800069c..bdf9cb23b 100644 --- a/api/v1/tickets/resolve.php +++ b/api/v1/tickets/resolve.php @@ -44,7 +44,7 @@ if (!empty($ticket_row)) { // Logging logTicketHistory($ticket_id, "Resolved via the API ($api_key_name)"); - logAudit("Ticket", "Resolved", "$ticket_prefix$ticket_number ticket via API ($api_key_name)", $client_id, $ticket_id); + logAudit("Ticket", "Resolve", "$ticket_prefix$ticket_number ticket via API ($api_key_name)", $client_id, $ticket_id); logAudit("API", "Success", "Resolved ticket $ticket_prefix$ticket_number via API ($api_key_name)", $client_id); } diff --git a/api/v1/vendors/unarchive.php b/api/v1/vendors/unarchive.php index 6b409375f..1b9866e54 100644 --- a/api/v1/vendors/unarchive.php +++ b/api/v1/vendors/unarchive.php @@ -24,7 +24,7 @@ if (!empty($vendor_id)) { $update_count = mysqli_affected_rows($mysqli); // Logging - logAudit("Vendor", "Unarchive", "$vendor_name unarchived via API ($api_key_name)", $client_id, $vendor_id); + logAudit("Vendor", "Restore", "$vendor_name unarchived via API ($api_key_name)", $client_id, $vendor_id); } } } diff --git a/client/post.php b/client/post.php index fd6da48c8..d155c0159 100644 --- a/client/post.php +++ b/client/post.php @@ -1270,7 +1270,7 @@ if (isset($_GET['stripe_save_card'])) { stripslashes((string) $card_brand), stripslashes((string) $last4), intval($exp_month), intval($exp_year)); } - logAudit("Stripe", "Update", "$session_contact_name saved payment method ($saved_payment_description) (PM: $payment_method_id)", $session_client_id); + logAudit("Stripe", "Create", "$session_contact_name saved payment method ($saved_payment_description) (PM: $payment_method_id)", $session_client_id); flashAlert("Payment method saved – thank you."); redirect("saved_payment_methods.php"); @@ -1371,7 +1371,7 @@ if (isset($_GET['delete_saved_payment'])) { "); } - logAudit("Stripe", "Update", "$session_contact_name deleted Stripe payment method $saved_payment_description (PM: $payment_method_id)", $session_client_id); + logAudit("Stripe", "Delete", "$session_contact_name deleted Stripe payment method $saved_payment_description (PM: $payment_method_id)", $session_client_id); flashAlert("Payment method $saved_payment_description removed."); diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php index 6f707f879..e2bbfcd28 100644 --- a/cron/nightly_tasks.php +++ b/cron/nightly_tasks.php @@ -404,7 +404,7 @@ while ($row = mysqli_fetch_assoc($sql_resolved_tickets_to_close)) { //Logging logTicketHistory($ticket_id, "Automatically closed after $config_ticket_autoclose_hours hours with no activity"); - logAudit("Ticket", "Closed", "$ticket_prefix$ticket_number auto closed", $client_id, $ticket_id); + logAudit("Ticket", "Close", "$ticket_prefix$ticket_number auto closed", $client_id, $ticket_id); triggerCustomAction('ticket_close', $ticket_id); diff --git a/db.sql b/db.sql index ef4cedb43..757f12e5f 100644 --- a/db.sql +++ b/db.sql @@ -1810,7 +1810,9 @@ CREATE TABLE `logs` ( `log_entity_id` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`log_id`), KEY `log_created_at` (`log_created_at`), - KEY `log_user_id` (`log_user_id`,`log_client_id`) + KEY `log_user_id` (`log_user_id`,`log_client_id`), + KEY `log_client_id` (`log_client_id`,`log_created_at`), + KEY `log_entity_id` (`log_entity_id`,`log_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/functions.php b/functions.php index c883a8e5a..a834eeba3 100644 --- a/functions.php +++ b/functions.php @@ -34,6 +34,7 @@ require_once __DIR__ . '/functions/credential.php'; require_once __DIR__ . '/functions/links.php'; require_once __DIR__ . '/functions/auth.php'; require_once __DIR__ . '/functions/logging.php'; +require_once __DIR__ . '/functions/audit_log.php'; require_once __DIR__ . '/functions/app.php'; require_once __DIR__ . '/functions/email.php'; require_once __DIR__ . '/functions/email_preview.php'; diff --git a/functions/audit_log.php b/functions/audit_log.php new file mode 100644 index 000000000..45b358fd6 --- /dev/null +++ b/functions/audit_log.php @@ -0,0 +1,343 @@ + Audit Logs lists it (admin/audit_logs.php) + * in views, filtered and exported the same way (auditLogFiltersSql()), each + * entry with its record when it still exists (auditLogRecords()) and a quick + * look (admin/modals/audit_log/audit_log.php). + * + * An entry's log_type says what kind of record it is about and log_entity_id + * which one; log_action is what was done, one verb (Create, Edit, Delete, + * Archive, Restore, ...); log_description says it in words, starting with who + * did it. + */ + +/* + * The kinds of record an entry can be about, by its log_type - each with its + * icon and, for the ones that are records, where to find the record: its + * table, id and name columns, its client column, and its page ('url') - for + * a record with a quick look rather than a page, its list with ?open= (or + * ?share=), which opens it. {id} is the record, {client} its client, and + * {client_q} "client_id=N&" when it has one. + */ +function auditLogKinds() { + return [ + 'Client' => ['icon' => 'fa-user-friends', 'table' => 'clients', 'id' => 'client_id', 'name' => 'client_name', 'url' => '/agent/client_overview.php?client_id={id}'], + 'Contact' => ['icon' => 'fa-address-card', 'table' => 'contacts', 'id' => 'contact_id', 'name' => 'contact_name', 'client' => 'contact_client_id', 'url' => '/agent/contact.php?client_id={client}&contact_id={id}'], + 'Location' => ['icon' => 'fa-map-marker-alt', 'table' => 'locations', 'id' => 'location_id', 'name' => 'location_name', 'client' => 'location_client_id', 'url' => '/agent/location.php?client_id={client}&location_id={id}'], + 'Vendor' => ['icon' => 'fa-building', 'table' => 'vendors', 'id' => 'vendor_id', 'name' => 'vendor_name', 'client' => 'vendor_client_id', 'url' => '/agent/vendor.php?{client_q}vendor_id={id}'], + 'Asset' => ['icon' => 'fa-desktop', 'table' => 'assets', 'id' => 'asset_id', 'name' => 'asset_name', 'client' => 'asset_client_id', 'url' => '/agent/asset.php?client_id={client}&asset_id={id}'], + 'Network' => ['icon' => 'fa-network-wired', 'table' => 'networks', 'id' => 'network_id', 'name' => 'network_name', 'client' => 'network_client_id', 'url' => '/agent/network.php?client_id={client}&network_id={id}'], + 'Rack' => ['icon' => 'fa-server', 'table' => 'racks', 'id' => 'rack_id', 'name' => 'rack_name', 'client' => 'rack_client_id', 'url' => '/agent/rack.php?{client_q}rack_id={id}'], + 'Credential' => ['icon' => 'fa-key', 'table' => 'credentials', 'id' => 'credential_id', 'name' => 'credential_name', 'client' => 'credential_client_id', 'url' => '/agent/credentials.php?{client_q}open={id}'], + 'Certificate' => ['icon' => 'fa-lock', 'table' => 'certificates', 'id' => 'certificate_id', 'name' => 'certificate_name', 'client' => 'certificate_client_id', 'url' => '/agent/certificates.php?{client_q}open={id}'], + 'Domain' => ['icon' => 'fa-globe', 'table' => 'domains', 'id' => 'domain_id', 'name' => 'domain_name', 'client' => 'domain_client_id', 'url' => '/agent/domains.php?{client_q}open={id}'], + 'Software' => ['icon' => 'fa-cube', 'table' => 'software', 'id' => 'software_id', 'name' => 'software_name', 'client' => 'software_client_id', 'url' => '/agent/license.php?client_id={client}&software_id={id}'], + 'Service' => ['icon' => 'fa-stream', 'table' => 'services', 'id' => 'service_id', 'name' => 'service_name', 'client' => 'service_client_id', 'url' => '/agent/service.php?client_id={client}&service_id={id}'], + 'Document' => ['icon' => 'fa-file-alt', 'table' => 'documents', 'id' => 'document_id', 'name' => 'document_name', 'client' => 'document_client_id', 'url' => '/agent/document.php?client_id={client}&document_id={id}'], + 'File' => ['icon' => 'fa-file', 'table' => 'files', 'id' => 'file_id', 'name' => 'file_name', 'client' => 'file_client_id', 'url' => '/agent/files.php?client_id={client}&open={id}'], + 'Share' => ['icon' => 'fa-share-alt', 'table' => 'shared_items', 'id' => 'item_id', 'name' => 'item_name', 'client' => 'item_client_id', 'url' => '/agent/shares.php?{client_q}share={id}'], + 'Ticket' => ['icon' => 'fa-life-ring', 'table' => 'tickets', 'id' => 'ticket_id', 'name' => "CONCAT(ticket_prefix, ticket_number, ' - ', ticket_subject)", 'client' => 'ticket_client_id', 'url' => '/agent/ticket.php?{client_q}ticket_id={id}'], + 'Project' => ['icon' => 'fa-project-diagram', 'table' => 'projects', 'id' => 'project_id', 'name' => "CONCAT(project_prefix, project_number, ' - ', project_name)", 'client' => 'project_client_id', 'url' => '/agent/project.php?{client_q}project_id={id}'], + 'Quote' => ['icon' => 'fa-comment-dollar', 'table' => 'quotes', 'id' => 'quote_id', 'name' => "CONCAT(quote_prefix, quote_number)", 'client' => 'quote_client_id', 'url' => '/agent/quote.php?quote_id={id}'], + 'Invoice' => ['icon' => 'fa-file-invoice', 'table' => 'invoices', 'id' => 'invoice_id', 'name' => "CONCAT(invoice_prefix, invoice_number)", 'client' => 'invoice_client_id', 'url' => '/agent/invoice.php?invoice_id={id}'], + 'Recurring Invoice' => ['icon' => 'fa-redo-alt', 'table' => 'recurring_invoices', 'id' => 'recurring_invoice_id', 'name' => "CONCAT(recurring_invoice_prefix, recurring_invoice_number)", 'client' => 'recurring_invoice_client_id', 'url' => '/agent/recurring_invoice.php?recurring_invoice_id={id}'], + 'Product' => ['icon' => 'fa-box-open', 'table' => 'products', 'id' => 'product_id', 'name' => 'product_name', 'url' => '/agent/product.php?product_id={id}'], + 'Expense' => ['icon' => 'fa-shopping-cart', 'table' => 'expenses', 'id' => 'expense_id', 'name' => 'expense_description', 'client' => 'expense_client_id', 'url' => '/agent/expenses.php?open={id}'], + 'Recurring Expense' => ['icon' => 'fa-redo-alt', 'table' => 'recurring_expenses', 'id' => 'recurring_expense_id', 'name' => 'recurring_expense_description', 'client' => 'recurring_expense_client_id', 'url' => '/agent/recurring_expenses.php?open={id}'], + 'Trip' => ['icon' => 'fa-route', 'table' => 'trips', 'id' => 'trip_id', 'name' => 'trip_purpose', 'client' => 'trip_client_id', 'url' => '/agent/trips.php?open={id}'], + 'Ticket Template' => ['icon' => 'fa-life-ring', 'table' => 'ticket_templates', 'id' => 'ticket_template_id', 'name' => 'ticket_template_name', 'url' => '/admin/ticket_template.php?ticket_template_id={id}'], + 'Project Template' => ['icon' => 'fa-project-diagram', 'table' => 'project_templates', 'id' => 'project_template_id', 'name' => 'project_template_name', 'url' => '/admin/project_template.php?project_template_id={id}'], + 'Document Template' => ['icon' => 'fa-file-alt', 'table' => 'document_templates', 'id' => 'document_template_id', 'name' => 'document_template_name', 'url' => '/admin/document_template.php?document_template_id={id}'], + 'Software Template' => ['icon' => 'fa-cube', 'table' => 'software_templates', 'id' => 'software_template_id', 'name' => 'software_template_name', 'url' => '/admin/software_template.php?software_template_id={id}'], + + // Not one record to open, or nothing to open it with - just an icon + 'Login' => ['icon' => 'fa-sign-in-alt'], + 'Login 2FA' => ['icon' => 'fa-shield-alt'], + 'Logout' => ['icon' => 'fa-sign-out-alt'], + 'Client Login' => ['icon' => 'fa-door-open'], + 'User' => ['icon' => 'fa-user'], + 'User Account' => ['icon' => 'fa-user-cog'], + 'User Role' => ['icon' => 'fa-user-shield'], + 'API' => ['icon' => 'fa-plug'], + 'API Key' => ['icon' => 'fa-key'], + 'Master Key' => ['icon' => 'fa-user-secret'], + 'Settings' => ['icon' => 'fa-cog'], + 'Backup' => ['icon' => 'fa-cloud-upload-alt'], + 'App' => ['icon' => 'fa-download'], + 'Cron' => ['icon' => 'fa-clock'], + 'Calendar' => ['icon' => 'fa-calendar-alt'], + 'Calendar Event' => ['icon' => 'fa-calendar-day'], + 'Recurring Ticket' => ['icon' => 'fa-redo-alt'], + 'Task' => ['icon' => 'fa-tasks'], + 'Ticket Reply' => ['icon' => 'fa-reply'], + 'Folder' => ['icon' => 'fa-folder'], + 'Document Version' => ['icon' => 'fa-history'], + 'Asset Interface' => ['icon' => 'fa-ethernet'], + 'IP Address' => ['icon' => 'fa-sitemap'], + 'Payment' => ['icon' => 'fa-credit-card'], + 'Income' => ['icon' => 'fa-hand-holding-usd'], + 'Revenue' => ['icon' => 'fa-hand-holding-usd'], + 'Credit' => ['icon' => 'fa-wallet'], + 'Account' => ['icon' => 'fa-piggy-bank'], + 'Account Transfer' => ['icon' => 'fa-exchange-alt'], + 'Transaction' => ['icon' => 'fa-exchange-alt'], + 'Reconciliation' => ['icon' => 'fa-balance-scale'], + 'Budget' => ['icon' => 'fa-chart-pie'], + 'Tax' => ['icon' => 'fa-percent'], + 'Stripe' => ['icon' => 'fa-stripe-s'], + 'Payment Provider' => ['icon' => 'fa-credit-card'], + 'Payment Method' => ['icon' => 'fa-credit-card'], + 'Email' => ['icon' => 'fa-envelope'], + 'Bulk Mail' => ['icon' => 'fa-mail-bulk'], + 'Notification' => ['icon' => 'fa-bell'], + 'Announcement' => ['icon' => 'fa-bullhorn'], + 'Tag' => ['icon' => 'fa-tag'], + 'Category' => ['icon' => 'fa-list-ul'], + 'Ticket Status' => ['icon' => 'fa-info-circle'], + 'SLA' => ['icon' => 'fa-stopwatch'], + 'Canned Response' => ['icon' => 'fa-comment-dots'], + 'Contract Template' => ['icon' => 'fa-file-contract'], + 'Vendor Template' => ['icon' => 'fa-building'], + 'Custom Field' => ['icon' => 'fa-th-list'], + 'Custom Link' => ['icon' => 'fa-external-link-alt'], + 'AI Provider' => ['icon' => 'fa-robot'], + 'AI Model' => ['icon' => 'fa-robot'], + 'Starter Content' => ['icon' => 'fa-seedling'], + 'Demo Data' => ['icon' => 'fa-flask'], + 'Report' => ['icon' => 'fa-chart-bar'], + 'Sharing' => ['icon' => 'fa-share-alt'], + 'Audit Log' => ['icon' => 'fa-history'], + ]; +} + +/** An entry's icon, by its type. */ +function auditLogTypeIcon($type) { + return auditLogKinds()[(string) $type]['icon'] ?? 'fa-circle'; +} + +/* + * How an action looks - its tone (a Bootstrap colour) and icon. Making + * things green, changing them blue, taking them away red, a failed or blocked + * sign-in red, a look at a secret or a file leaving amber, the rest grey. + */ +function auditLogActionStyle($action) { + $action = (string) $action; + $styles = [ + 'Create' => ['success', 'fa-plus'], 'Bulk Create' => ['success', 'fa-plus'], 'Import' => ['success', 'fa-file-import'], 'Upload' => ['success', 'fa-upload'], + 'Edit' => ['primary', 'fa-pen'], 'Bulk Edit' => ['primary', 'fa-pen'], 'Update' => ['primary', 'fa-sync-alt'], 'Modify' => ['primary', 'fa-pen'], 'Rename' => ['primary', 'fa-i-cursor'], + 'Move' => ['primary', 'fa-arrows-alt'], 'Merge' => ['primary', 'fa-code-branch'], 'Link' => ['primary', 'fa-link'], 'Unlink' => ['secondary', 'fa-unlink'], + 'Reply' => ['primary', 'fa-reply'], 'Resolve' => ['success', 'fa-check'], 'Close' => ['secondary', 'fa-check-double'], 'Reopen' => ['primary', 'fa-redo'], + 'Delete' => ['danger', 'fa-trash-alt'], 'Bulk Delete' => ['danger', 'fa-trash-alt'], 'Revoke' => ['danger', 'fa-ban'], + 'Archive' => ['secondary', 'fa-archive'], 'Bulk Archive' => ['secondary', 'fa-archive'], 'Restore' => ['info', 'fa-redo'], 'Bulk Restore' => ['info', 'fa-redo'], + 'Success' => ['success', 'fa-check-circle'], 'Failed' => ['danger', 'fa-times-circle'], 'Failure' => ['danger', 'fa-times-circle'], 'MFA Failed' => ['danger', 'fa-shield-alt'], 'Blocked' => ['danger', 'fa-ban'], 'Denied' => ['danger', 'fa-ban'], + 'View' => ['warning', 'fa-eye'], 'View TOTP' => ['warning', 'fa-eye'], 'View As' => ['warning', 'fa-user-secret'], 'Export' => ['warning', 'fa-download'], 'Download' => ['warning', 'fa-download'], + 'Share' => ['warning', 'fa-share-alt'], 'Send' => ['primary', 'fa-paper-plane'], 'Email' => ['primary', 'fa-envelope'], 'Payment' => ['success', 'fa-credit-card'], 'Auto Payment' => ['success', 'fa-credit-card'], + 'Error' => ['danger', 'fa-exclamation-triangle'], + 'Activate' => ['success', 'fa-toggle-on'], 'Disable' => ['secondary', 'fa-ban'], 'Cancel' => ['secondary', 'fa-ban'], 'Bulk Cancel' => ['secondary', 'fa-ban'], 'End' => ['secondary', 'fa-stop-circle'], + 'Dismiss' => ['secondary', 'fa-times'], 'Discard' => ['danger', 'fa-trash-alt'], 'Import Rule Delete' => ['danger', 'fa-trash-alt'], 'Undo' => ['secondary', 'fa-undo'], 'Reimbursement Undo' => ['secondary', 'fa-undo'], + 'Unshare' => ['secondary', 'fa-unlink'], 'Unbill' => ['secondary', 'fa-unlink'], 'Read' => ['warning', 'fa-eye'], 'Keys' => ['primary', 'fa-key'], 'Rotate' => ['primary', 'fa-sync-alt'], 'Refresh' => ['primary', 'fa-sync-alt'], + 'Change' => ['primary', 'fa-pen'], 'Stock' => ['primary', 'fa-boxes'], 'Seats' => ['primary', 'fa-users'], 'Task' => ['primary', 'fa-tasks'], 'Transfer' => ['primary', 'fa-exchange-alt'], 'Bulk Transfer' => ['primary', 'fa-exchange-alt'], + 'Statement' => ['primary', 'fa-file-invoice'], 'Receipt' => ['primary', 'fa-receipt'], 'Queue' => ['primary', 'fa-stream'], 'Run' => ['primary', 'fa-play'], 'Move On' => ['primary', 'fa-forward'], + 'Check' => ['info', 'fa-check'], 'Review' => ['info', 'fa-clipboard-check'], 'Save' => ['success', 'fa-save'], 'Bill' => ['success', 'fa-file-invoice-dollar'], 'Renew' => ['success', 'fa-redo-alt'], + 'Reimburse' => ['success', 'fa-hand-holding-usd'], 'Finish' => ['success', 'fa-flag-checkered'], + ]; + $style = $styles[$action] ?? ['secondary', 'fa-circle']; + return ['tone' => $style[0], 'icon' => $style[1]]; +} + +/** An action as a soft pill, in its tone. */ +function auditLogActionPill($action) { + $style = auditLogActionStyle($action); + $tone = $style['tone']; + return '' . escapeHtml($action) . ''; +} + +/* + * The list's views - key => label, icon, title and the SQL on logs that picks + * them. Sign-ins are agents and portal contacts signing in and out; security + * is the sign-ins that failed or were blocked, access denied, the portal + * viewed as a contact, and changes to users, roles, API keys, the master key, + * settings and backups; data out is passwords looked at, exports, downloads + * and shared links. + */ +function auditLogViews() { + $signins = "log_type IN ('Login', 'Login 2FA', 'Logout', 'Client Login')"; + $data_out = "(log_action IN ('View', 'View TOTP', 'Export', 'Download', 'Share') OR log_type IN ('Share', 'Sharing'))"; + return [ + '' => ['label' => 'All', 'icon' => 'fa-list', 'title' => 'Everything', 'where' => ''], + 'changes' => ['label' => 'Changes', 'icon' => 'fa-pen', 'title' => 'Records made, changed, archived, restored and deleted', 'where' => "NOT $signins AND NOT $data_out"], + 'deletions' => ['label' => 'Deletions', 'icon' => 'fa-trash-alt', 'title' => 'Records deleted for good', 'where' => "log_action IN ('Delete', 'Bulk Delete')"], + 'signins' => ['label' => 'Sign-ins', 'icon' => 'fa-sign-in-alt', 'title' => 'Agents and portal contacts signing in and out', 'where' => $signins], + 'security' => ['label' => 'Security', 'icon' => 'fa-shield-alt', 'title' => 'Failed and blocked sign-ins, access denied, the portal viewed as a contact, and changes to users, roles, API keys, the master key, settings and backups', + 'where' => "(($signins AND log_action IN ('Failed', 'MFA Failed', 'Blocked')) OR log_action IN ('Denied', 'View As') OR log_type IN ('User', 'User Account', 'User Role', 'API Key', 'Master Key', 'Settings', 'Backup', 'Identity Provider'))"], + 'data' => ['label' => 'Data out', 'icon' => 'fa-file-export', 'title' => 'Passwords looked at, exports, downloads and shared links', 'where' => $data_out], + ]; +} + +/* + * The audit log's filters as SQL on logs (joined to users as the person who + * did it and clients) - from the list page's GET or the export window's POST: + * q (the description, type, action, IP, browser, who or client), client, + * user ('system' for ITFlow itself), type, action, entity (one record, with + * its type), view (auditLogViews()) and dtf / dtt. $summary gets them in + * words, for an export. + */ +function auditLogFiltersSql(array $src, &$summary = []) { + $summary = []; + $sql = '1 = 1'; + // One value each - anything else (a list in the query string) is no filter + $src = array_map(function ($value) { + return is_scalar($value) ? trim((string) $value) : ''; + }, $src); + + $q = $src['q'] ?? ''; + if ($q !== '') { + $like = escapeSql($q); + $sql .= " AND (log_description LIKE '%$like%' OR log_type LIKE '%$like%' OR log_action LIKE '%$like%' OR log_ip LIKE '%$like%' OR log_user_agent LIKE '%$like%' + OR user_name LIKE '%$like%' OR client_name LIKE '%$like%')"; + $summary['Search'] = $q; + } + $client = intval($src['client'] ?? 0); + if ($client) { + $sql .= " AND log_client_id = $client"; + $summary['Client'] = (string) getFieldById('clients', $client, 'client_name'); + } + $user = $src['user'] ?? ''; + if ($user === 'system') { + $sql .= " AND log_user_id = 0"; + $summary['Who'] = 'ITFlow itself'; + } elseif (intval($user)) { + $sql .= " AND log_user_id = " . intval($user); + $summary['Who'] = (string) getFieldById('users', intval($user), 'user_name'); + } + foreach (['type' => ['log_type', 'Type'], 'action' => ['log_action', 'Action']] as $key => [$column, $label]) { + $value = $src[$key] ?? ''; + if ($value !== '') { + $sql .= " AND $column = '" . escapeSql($value) . "'"; + $summary[$label] = $value; + } + } + $entity = intval($src['entity'] ?? 0); + if ($entity) { + $sql .= " AND log_entity_id = $entity"; + $summary['Record'] = "#$entity"; + } + $views = auditLogViews(); + $view = $src['view'] ?? ''; + if (isset($views[$view]) && $views[$view]['where'] !== '') { + $sql .= " AND {$views[$view]['where']}"; + $summary['Showing'] = $views[$view]['label']; + } + // A half-open range on the column itself, so KEY log_created_at serves it + $dtf = $src['dtf'] ?? ''; + $dtt = $src['dtt'] ?? ''; + $dtf = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtf) ? $dtf : '1970-01-01'; + $dtt = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dtt) ? $dtt : '2099-12-31'; + if ($dtf !== '1970-01-01' || $dtt !== '2099-12-31') { + $sql .= " AND log_created_at >= '$dtf 00:00:00' AND log_created_at < '$dtt' + INTERVAL 1 DAY"; + $range = formatExportDateRange($dtf, $dtt); + if ($range) { + $summary['Dated'] = $range; + } + } + return $sql; +} + +/** The joins the filters need - who did it, and the client. */ +function auditLogJoinsSql() { + return "LEFT JOIN users ON user_id = log_user_id AND log_user_id > 0 LEFT JOIN clients ON client_id = log_client_id AND log_client_id > 0"; +} + +/* + * The records entries are about, where they still exist - ["type|id" => + * ['name', 'url']] - one query per kind of record on the page. + */ +function auditLogRecords(array $rows) { + global $mysqli; + $kinds = auditLogKinds(); + $wanted = []; + foreach ($rows as $row) { + $type = (string) $row['log_type']; + $id = intval($row['log_entity_id']); + if ($id > 0 && isset($kinds[$type]['table'])) { + $wanted[$type][$id] = true; + } + } + $records = []; + foreach ($wanted as $type => $ids) { + $kind = $kinds[$type]; + $client = isset($kind['client']) ? $kind['client'] : '0'; + $sql = mysqli_query($mysqli, "SELECT {$kind['id']} AS record_id, {$kind['name']} AS record_name, $client AS record_client + FROM {$kind['table']} WHERE {$kind['id']} IN (" . implode(',', array_map('intval', array_keys($ids))) . ")"); + while ($sql && $row = mysqli_fetch_assoc($sql)) { + $id = intval($row['record_id']); + $client_id = intval($row['record_client']); + $fill = function ($template) use ($id, $client_id) { + return str_replace(['{id}', '{client}', '{client_q}'], [$id, $client_id, $client_id ? "client_id=$client_id&" : ''], $template); + }; + $records["$type|$id"] = ['name' => (string) $row['record_name'], 'url' => $fill($kind['url'])]; + } + } + return $records; +} + +/** The record an entry is about, as a link - '' when there is none to open, or it is gone. */ +function auditLogRecordLink(array $row, array $records, $label = null) { + $record = $records[$row['log_type'] . '|' . intval($row['log_entity_id'])] ?? null; + if (!$record) { + return ''; + } + $text = $label === null ? escapeHtml($record['name']) : $label; + return '' . $text . ''; +} + +/* + * What an entry says, without who did it at the start when it is the person + * shown beside it - "Johnny Test created client Acme" reads "Created client + * Acme". + */ +function auditLogDescription(array $row) { + $description = (string) $row['log_description']; + $name = trim((string) ($row['user_name'] ?? '')); + if ($name !== '' && stripos($description, $name . ' ') === 0) { + $description = ucfirst(substr($description, strlen($name) + 1)); + } + return $description; +} + +/* + * Who did it - ['name', 'kind' (agent, portal - a client contact - or + * system), 'html' (their avatar or tile and name)]. No one is ITFlow itself: + * cron, the API, the email parser. + */ +function auditLogWho(array $row) { + $user_id = intval($row['log_user_id']); + if (!$user_id || $row['user_name'] === null) { + $name = $user_id ? 'A user no longer here' : 'ITFlow'; + return ['name' => $name, 'kind' => 'system', 'html' => '' . escapeHtml($name) . '']; + } + $portal = intval($row['user_type'] ?? 1) === 2; + $html = '' . userAvatar($user_id, $row['user_name'], (string) ($row['user_avatar'] ?? ''), 'xs') + . '' . escapeHtml($row['user_name']) . '' + . ($portal ? 'Portal' : '') . ''; + return ['name' => (string) $row['user_name'], 'kind' => $portal ? 'portal' : 'agent', 'html' => $html]; +} + +/* + * A browser's user agent as its system and browser, in words - "Windows · + * Chrome". One that is not a browser - a script calling the API, curl - is + * its own first word: "python-requests/2.34.2". + */ +function auditLogDevice($user_agent) { + $user_agent = trim((string) $user_agent); + if ($user_agent === '') { + return ''; + } + $os = trim(strip_tags(getOS($user_agent))); + $browser = trim(strip_tags(getWebBrowser($user_agent))); + $device = implode(' · ', array_filter([$os !== '-' ? $os : '', $browser !== '-' ? $browser : ''])); + return $device !== '' ? $device : mb_strimwidth(strtok($user_agent, ' '), 0, 40, '…'); +} diff --git a/functions/auth.php b/functions/auth.php index f1681773b..5b6670ec7 100644 --- a/functions/auth.php +++ b/functions/auth.php @@ -227,7 +227,7 @@ function enforceClientAccess($client_id = null) { LIMIT 1"; $deny_result = mysqli_query($mysqli, $deny_sql); if ($deny_result && mysqli_num_rows($deny_result) > 0) { - logAudit('Client', 'Access', "$session_name was denied permission from accessing client", $client_id, $client_id); + logAudit('Client', 'Denied', "$session_name was denied permission from accessing client", $client_id, $client_id); flashAlert('Access Denied - You do not have permission to access that client!', 'error'); redirect('clients.php'); } @@ -262,7 +262,7 @@ function enforceClientAccess($client_id = null) { logAudit( 'Client', - 'Access', + 'Denied', "$session_name was denied permission from accessing client", $client_id, $client_id diff --git a/functions/expenses.php b/functions/expenses.php index ae5d5d55a..91584783b 100644 --- a/functions/expenses.php +++ b/functions/expenses.php @@ -1570,7 +1570,7 @@ function expenseReimburse($user_id, array $expense_ids, array $opts, &$error = ' mysqli_query($mysqli, "UPDATE reimbursements SET reimbursement_amount = $amount, reimbursement_expense_id = $cash_id WHERE reimbursement_id = $reimbursement_id"); logAudit('Expense', 'Reimburse', escapeSql("$by paid {$waiting['user_name']} back " . billingMoney($amount, $currency) . ' for ' . ($count === 1 ? '1 expense' : "$count expenses") - . " from {$accounts[$account]['account_name']}"), 0, $reimbursement_id); + . " from {$accounts[$account]['account_name']}"), 0, $cash_id); return ['reimbursement_id' => $reimbursement_id, 'amount' => $amount, 'count' => $count, 'user_name' => $waiting['user_name'], 'currency' => $currency]; } diff --git a/functions/export.php b/functions/export.php index 96a4fdf18..67e8eeea8 100644 --- a/functions/export.php +++ b/functions/export.php @@ -478,6 +478,22 @@ function getExportColumns($export_type) { 'user_created_at' => ['label' => 'Creation Date'], ], + // Audit log entries - handler adds who did it, the record and the device (functions/audit_log.php) + 'audit_logs' => [ + 'log_created_at' => ['label' => 'When'], + 'audit_who' => ['label' => 'Who'], + 'user_email' => ['label' => 'Email', 'default' => false], + 'client_name' => ['label' => 'Client'], + 'log_type' => ['label' => 'Type'], + 'log_action' => ['label' => 'Action'], + 'log_description' => ['label' => 'Description', 'weight' => 3], + 'audit_record' => ['label' => 'Record', 'weight' => 2, 'default' => false], + 'log_ip' => ['label' => 'IP Address'], + 'audit_device' => ['label' => 'Device'], + 'log_user_agent' => ['label' => 'User Agent', 'weight' => 2, 'default' => false], + 'log_id' => ['label' => 'Entry', 'default' => false], + ], + ]; return $registry[$export_type] ?? []; diff --git a/functions/ticket.php b/functions/ticket.php index c0671a427..b278dff6c 100644 --- a/functions/ticket.php +++ b/functions/ticket.php @@ -995,7 +995,7 @@ function ticketResolve($row, $how = '') { syncTicketSlaClock($ticket_id); setTicketResolutionSlaMet($ticket_id); logTicketHistory($ticket_id, "$session_name resolved the ticket$how"); - logAudit("Ticket", "Resolved", "$session_name resolved ticket " . escapeSql(ticketReference($row)) . " - " . escapeSql($row['ticket_subject']), intval($row['ticket_client_id']), $ticket_id); + logAudit("Ticket", "Resolve", "$session_name resolved ticket " . escapeSql(ticketReference($row)) . " - " . escapeSql($row['ticket_subject']), intval($row['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_resolve', $ticket_id); } @@ -1009,7 +1009,7 @@ function ticketReopen($row, $status_id = TICKET_STATUS_OPEN, $how = '') { resetTicketResolutionSla($ticket_id); $status_name = escapeSql(getTicketStatusName($status_id)); logTicketHistory($ticket_id, "$session_name reopened the ticket" . ($status_id === TICKET_STATUS_OPEN ? '' : " to $status_name") . $how); - logAudit("Ticket", "Reopened", "$session_name reopened ticket " . escapeSql(ticketReference($row)), intval($row['ticket_client_id']), $ticket_id); + logAudit("Ticket", "Reopen", "$session_name reopened ticket " . escapeSql(ticketReference($row)), intval($row['ticket_client_id']), $ticket_id); triggerCustomAction('ticket_update', $ticket_id); }