Files
itflow/functions/audit_log.php
johnnyq 2311d1f9d8 Speed up Admin Audit Logs, Sign-ins and Users, Transactions, Income, Invoices, Projects and the invoice page on installs with years of history: database 3.6.6 adds logs (log_user_id, log_type, log_action), logs (log_type, log_action, log_user_id, log_ip, log_created_at) and tickets (ticket_invoice_id), each only where missing and in db.sql, leaving out one it cannot add with a warning in App Logs; Audit Logs drops SQL_CALC_FOUND_ROWS and counts its views with one GROUP BY log_type, log_action in the new auditLogViewCounts, which also hands back the types and actions in the view so auditLogKindsSql finds a sparse view or action on the index, and builds its who, client, type and action lists with auditLogDistinctSql in place of EXISTS and DISTINCT over the log, as the export window now does; Sign-ins reads only the new log_type index, counts unknown emails for the page's addresses in their own query and ends every sort on log_ip or log_user_id so tied rows keep one order; the new listNewestFirst in functions/app.php pages a newest-first list from the last month, year and four years back before the whole of it, used by Transactions without the running balance and by Income, which both take their count from their pill in place of SQL_CALC_FOUND_ROWS, with incomeUnionSql(false) leaving out the name joins for Income's counts when there is no search; Invoices replaces the LEFT JOIN of every payment grouped by invoice with a paid lookup for the page's invoices and the unpaid ones in the totals; the project lookups, through the new PROJECT_TICKETS_SQL, the client onboarding ticket counts and the expense refund lookups also say the column is > 0, so the database stays on the index where nearly every row has 0 and its statistics say a lookup finds every row, Projects having read 33.6 million rows with no ticket on a project; checked on an eight-year test install against the old code over 103 filter, sort and page combinations and a crawl of all 233 pages; add the changes to the 26.11.8 changelog section
2026-11-13 12:58:04 -05:00

557 lines
37 KiB
PHP

<?php
/*
* ITFlow - the audit log, read: who did what, when, from where - every change,
* sign-in, look at a secret and export logAudit() (functions/logging.php)
* writes to the logs table. Admin > 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). A client's Audit Log
* (agent/audit_log.php) and Your account > Activity list the same way, and
* record pages show their own history (auditLogHistory()) - for an agent,
* only the kinds of entry their role can see (auditLogRoleSql()).
*
* 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, the part of ITFlow ('module') whose read access lets an agent see
* entries of that kind (none: admins only - sign-ins, users, settings,
* templates and the like), 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', 'module' => 'module_client', 'table' => 'clients', 'id' => 'client_id', 'name' => 'client_name', 'url' => '/agent/client_overview.php?client_id={id}'],
'Contact' => ['icon' => 'fa-address-card', 'module' => 'module_client', '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', 'module' => 'module_client', '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', 'module' => 'module_client', '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', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_credential', 'table' => 'credentials', 'id' => 'credential_id', 'name' => 'credential_name', 'client' => 'credential_client_id', 'url' => '/agent/credentials.php?{client_q}open={id}'],
'Certificate' => ['icon' => 'fa-lock', 'module' => 'module_support', 'table' => 'certificates', 'id' => 'certificate_id', 'name' => 'certificate_name', 'client' => 'certificate_client_id', 'url' => '/agent/certificates.php?{client_q}open={id}'],
'Domain' => ['icon' => 'fa-globe', 'module' => 'module_support', 'table' => 'domains', 'id' => 'domain_id', 'name' => 'domain_name', 'client' => 'domain_client_id', 'url' => '/agent/domains.php?{client_q}open={id}'],
'Software' => ['icon' => 'fa-cube', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_client', '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', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_support', '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', 'module' => 'module_sales', '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', 'module' => 'module_sales', 'table' => 'invoices', 'id' => 'invoice_id', 'name' => "CONCAT(invoice_prefix, invoice_number)", 'client' => 'invoice_client_id', 'url' => '/agent/invoice.php?invoice_id={id}'],
'Contract' => ['icon' => 'fa-file-contract', 'module' => 'module_sales', 'table' => 'contracts', 'id' => 'contract_id', 'name' => 'contract_name', 'client' => 'contract_client_id', 'url' => '/agent/contract.php?contract_id={id}'],
'Recurring Invoice' => ['icon' => 'fa-redo-alt', 'module' => 'module_sales', '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', 'module' => 'module_sales', 'table' => 'products', 'id' => 'product_id', 'name' => 'product_name', 'url' => '/agent/product.php?product_id={id}'],
'Expense' => ['icon' => 'fa-shopping-cart', 'module' => 'module_financial', 'table' => 'expenses', 'id' => 'expense_id', 'name' => 'expense_description', 'client' => 'expense_client_id', 'url' => '/agent/expenses.php?open={id}'],
'Recurring Expense' => ['icon' => 'fa-redo-alt', 'module' => 'module_financial', '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', 'module' => 'module_financial', '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}'],
'Vendor Template' => ['icon' => 'fa-building', 'table' => 'vendor_templates', 'id' => 'vendor_template_id', 'name' => 'vendor_template_name', 'url' => '/admin/vendor_template.php?vendor_template_id={id}'],
'Managed Instance' => ['icon' => 'fa-network-wired', 'table' => 'managed_instances', 'id' => 'managed_instance_id', 'name' => 'managed_instance_name', 'url' => '/admin/managed_instance.php?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', 'module' => 'module_client'],
'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', 'module' => 'module_client'],
'Calendar Event' => ['icon' => 'fa-calendar-day', 'module' => 'module_client'],
'Recurring Ticket' => ['icon' => 'fa-redo-alt', 'module' => 'module_support'],
'Task' => ['icon' => 'fa-tasks', 'module' => 'module_support'],
'Ticket Reply' => ['icon' => 'fa-reply', 'module' => 'module_support'],
'Folder' => ['icon' => 'fa-folder', 'module' => 'module_client'],
'Document Version' => ['icon' => 'fa-history', 'module' => 'module_support'],
'Asset Interface' => ['icon' => 'fa-ethernet', 'module' => 'module_support'],
'IP Address' => ['icon' => 'fa-sitemap', 'module' => 'module_support'],
'Payment' => ['icon' => 'fa-credit-card', 'module' => 'module_financial'],
'Income' => ['icon' => 'fa-hand-holding-usd', 'module' => 'module_financial'],
'Revenue' => ['icon' => 'fa-hand-holding-usd', 'module' => 'module_financial'],
'Credit' => ['icon' => 'fa-wallet', 'module' => 'module_sales'],
'Account' => ['icon' => 'fa-piggy-bank', 'module' => 'module_financial'],
'Account Transfer' => ['icon' => 'fa-exchange-alt', 'module' => 'module_financial'],
'Transaction' => ['icon' => 'fa-exchange-alt', 'module' => 'module_financial'],
'Reconciliation' => ['icon' => 'fa-balance-scale', 'module' => 'module_financial'],
'Budget' => ['icon' => 'fa-chart-pie', 'module' => 'module_financial'],
'Tax' => ['icon' => 'fa-percent'],
'Stripe' => ['icon' => 'fa-credit-card', 'module' => 'module_financial'],
'Payment Provider' => ['icon' => 'fa-credit-card'],
'Payment Method' => ['icon' => 'fa-credit-card', 'module' => 'module_financial'],
'Email' => ['icon' => 'fa-envelope'],
'Bulk Mail' => ['icon' => 'fa-mail-bulk'],
'Text Message' => ['icon' => 'fa-sms'],
'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'],
'Custom Field' => ['icon' => 'fa-th-list'],
'Custom Link' => ['icon' => 'fa-external-link-alt'],
'AI Provider' => ['icon' => 'fa-robot'],
'AI Model' => ['icon' => 'fa-brain'],
'Starter Content' => ['icon' => 'fa-seedling'],
'Demo Data' => ['icon' => 'fa-flask'],
'Report' => ['icon' => 'fa-chart-bar', 'module' => 'module_reporting'],
'Sharing' => ['icon' => 'fa-share-alt'],
'Audit Log' => ['icon' => 'fa-history'],
'App Log' => ['icon' => 'fa-clipboard-list'],
'Management' => ['icon' => 'fa-handshake'],
];
}
/** 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'], 'Alert' => ['danger', 'fa-bell'], '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'], 'Sign' => ['success', 'fa-signature'], 'Withdraw' => ['secondary', 'fa-undo'], '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'],
'Invite' => ['primary', 'fa-user-plus'], 'Onboard' => ['primary', 'fa-route'], 'Sign Up Requested' => ['secondary', 'fa-user-plus'], 'Sign Up Sent' => ['primary', 'fa-paper-plane'], 'Sign Up Refused' => ['warning', 'fa-user-times'],
'Connect' => ['success', 'fa-plug'], 'Disconnect' => ['secondary', 'fa-unlink'], 'Remove' => ['danger', 'fa-unlink'], 'Release' => ['secondary', 'fa-unlink'], 'Code' => ['primary', 'fa-key'],
'Mode' => ['warning', 'fa-lock'], 'Support' => ['warning', 'fa-headset'],
];
$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 '<span class="badge rounded-pill bg-' . $tone . '-subtle text-' . $tone . '-emphasis border border-' . $tone . '-subtle fw-normal text-nowrap"><i class="fas ' . $style['icon'] . ' me-1"></i>' . escapeHtml($action) . '</span>';
}
/*
* 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 turned away and the admins told about
* them, API calls turned away, access denied, the portal viewed as a contact,
* app log entries cleared, and changes to users, roles, API keys, the master
* key, settings and backups; data out is passwords looked at, exports,
* downloads and shared links.
*
* Each where is on log_type and log_action and nothing else -
* auditLogViewCounts() counts them from those two alone.
*/
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 turned away sign-ins and the alerts about them, API calls turned away, access denied, the portal viewed as a contact, app logs cleared, and changes to users, roles, API keys, the master key, settings and backups',
'where' => "(($signins AND log_action IN ('Failed', 'MFA Failed', 'Blocked', 'Alert')) OR (log_type = 'API' AND log_action = 'Failed') OR log_action IN ('Denied', 'View As') OR (log_type = 'App Log' AND log_action = 'Delete') 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. */
/*
* How many entries each view (auditLogViews()) holds for the rest of the
* filters in $src - [view => count], '' the lot - and $kinds, the types and
* actions in $view_for as [log_type => [log_action, ...]].
*
* Counted by type and action and then added up, where a SUM() of each view's
* condition over every entry was: a view's where is on log_type and
* log_action alone, so the counts come from KEY log_type, without reading an
* entry. The users and clients joins only come in for a search, which looks
* at their names - otherwise a LEFT JOIN on their keys counts the same.
*/
function auditLogViewCounts(array $src, $view_for = '', &$kinds = []) {
global $mysqli;
$views = auditLogViews();
$flags = [];
foreach ($views as $key => $definition) {
$flags[] = ($definition['where'] === '' ? '1' : "({$definition['where']})") . " AS `view_$key`";
}
$q = is_scalar($src['q'] ?? null) ? trim((string) $src['q']) : '';
$joins = $q !== '' ? auditLogJoinsSql() : '';
$sql = mysqli_query($mysqli, "SELECT log_type, log_action, COUNT(*) AS n, " . implode(', ', $flags) . " FROM logs $joins
WHERE " . auditLogFiltersSql(array_merge($src, ['view' => ''])) . "
GROUP BY log_type, log_action");
$counts = array_fill_keys(array_keys($views), 0);
$kinds = [];
while ($row = mysqli_fetch_assoc($sql)) {
foreach (array_keys($views) as $key) {
if (intval($row["view_$key"])) {
$counts[$key] += intval($row['n']);
if ($key === $view_for) {
$kinds[$row['log_type']][] = $row['log_action'];
}
}
}
}
return $counts;
}
/*
* $kinds from auditLogViewCounts() as SQL - the entries of those types and
* actions, by the index, where a view's condition or an action alone isn't
* something the index can find. '0' for none.
*/
function auditLogKindsSql(array $kinds) {
$or = [];
foreach ($kinds as $type => $actions) {
$or[] = "(log_type = '" . escapeSql($type) . "' AND log_action IN ('" . implode("', '", array_map('escapeSql', $actions)) . "'))";
}
return $or ? '(' . implode(' OR ', $or) . ')' : '0';
}
/*
* The values of $columns (log_user_id, log_client_id, log_type, or log_type,
* log_action) found in the log, each once - as a GROUP BY on the index that
* leads with them, so the database steps from one value to the next on the
* index and reads nothing in between. EXISTS (SELECT 1 FROM logs ...) a user
* or a client, and DISTINCT, both read the whole log.
*/
function auditLogDistinctSql($columns) {
return "SELECT $columns FROM logs GROUP BY $columns";
}
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 '<a href="' . escapeHtml($record['url']) . '" title="Open ' . escapeHtml($record['name']) . '">' . $text . '</a>';
}
/*
* 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. A failed sign in names the account it was
* to, marked Their account - it may not have been them.
*/
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' => '<span class="d-inline-flex align-items-center gap-2 text-body-secondary"><span class="itflow-asset-icon itflow-asset-icon-xs itflow-asset-tone-other" aria-hidden="true"><i class="fas fa-fw ' . ($user_id ? 'fa-user-slash' : 'fa-cog') . '"></i></span>' . escapeHtml($name) . '</span>'];
}
$portal = intval($row['user_type'] ?? 1) === 2;
// A failed sign in is to their account, not necessarily by them
$against = in_array($row['log_type'] ?? '', SIGN_IN_TYPES, true) && in_array($row['log_action'] ?? '', SIGN_IN_FAILED, true);
$html = '<span class="d-inline-flex align-items-center gap-2 text-nowrap">' . userAvatar($user_id, $row['user_name'], (string) ($row['user_avatar'] ?? ''), 'xs')
. '<span class="text-truncate">' . escapeHtml($row['user_name']) . '</span>'
. ($portal ? '<span class="badge rounded-pill text-bg-light border fw-normal" title="A client contact, in the client portal">Portal</span>' : '')
. ($against ? '<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis border border-danger-subtle fw-normal" title="A failed sign in to their account - not necessarily by them" data-their-account>Their account</span>' : '') . '</span>';
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, '…');
}
/*
* What of the audit log this agent may see, as SQL on logs: everything for
* an admin; otherwise the kinds of entry whose part of ITFlow their role can
* read (auditLogKinds() 'module') - not sign-ins, users, settings or the
* like - and never another agent turned away from a client.
*/
function auditLogRoleSql() {
global $session_is_admin;
if (!empty($session_is_admin)) {
return '1 = 1';
}
$types = [];
foreach (auditLogKinds() as $type => $kind) {
if (!empty($kind['module']) && lookupUserPermission($kind['module']) >= 1) {
$types[] = "'" . escapeSql($type) . "'";
}
}
return $types ? "log_type IN (" . implode(', ', $types) . ") AND log_action <> 'Denied'" : '1 = 0';
}
/*
* A record's history - its audit log entries, newest first, that this agent
* may see: ['type', 'id', 'client_id', 'rows' (up to $limit, with who did
* it), 'count' (all of them)].
*/
function auditLogHistory($type, $id, $limit = 50) {
global $mysqli;
$type = (string) $type;
$id = intval($id);
$limit = max(1, intval($limit));
$where = "log_type = '" . escapeSql($type) . "' AND log_entity_id = $id AND " . auditLogRoleSql();
$history = ['type' => $type, 'id' => $id, 'client_id' => 0, 'rows' => [], 'count' => 0];
if ($id <= 0) {
return $history;
}
$history['count'] = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $where"))[0]);
$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_avatar, user_type
FROM logs LEFT JOIN users ON user_id = log_user_id AND log_user_id > 0
WHERE $where
ORDER BY log_id DESC LIMIT $limit");
while ($row = mysqli_fetch_assoc($sql)) {
$history['rows'][] = $row;
$history['client_id'] = $history['client_id'] ?: intval($row['log_client_id']);
}
return $history;
}
/*
* Where all of a record's history is: Admin > Audit Logs for an admin, the
* client's Audit Log for anyone else when the record is a client's, and
* nowhere ('') otherwise.
*/
function auditLogHistoryUrl(array $history, $client_id = 0) {
global $session_is_admin;
$query = 'type=' . rawurlencode($history['type']) . '&entity=' . intval($history['id']);
if (!empty($session_is_admin)) {
return '/admin/audit_logs.php?' . $query;
}
$client_id = intval($client_id) ?: intval($history['client_id']);
return $client_id ? "/agent/audit_log.php?client_id=$client_id&$query" : '';
}
/*
* A record's history as a list - each entry's action as a tile in its tone,
* what it says, who did it with their picture, when and, for an admin, from
* where - with how many more there are and a link to all of them. $opts:
* 'client_id' (the record's client, for the link), 'empty' (what to say when
* there is nothing), 'flush' (false for a list with its own border, in a
* quick look), 'compact' (no IP address, for a narrow card).
*/
function auditLogHistoryHtml(array $history, array $opts = []) {
global $session_is_admin;
if (!$history['rows']) {
return '<div class="itflow-asset-empty" data-audit-history-empty>' . escapeHtml($opts['empty'] ?? 'Nothing logged yet.') . '</div>';
}
$flush = $opts['flush'] ?? true;
$html = '<ul class="list-group ' . ($flush ? 'list-group-flush' : '') . ' itflow-audit-history" data-audit-history="' . escapeHtml($history['type']) . '">';
foreach ($history['rows'] as $row) {
$style = auditLogActionStyle($row['log_action']);
$who = auditLogWho($row);
$ip = trim((string) $row['log_ip']);
$html .= '<li class="list-group-item d-flex gap-3 py-2" data-log-id="' . intval($row['log_id']) . '">'
. '<span class="itflow-asset-icon itflow-asset-icon-sm bg-' . $style['tone'] . '-subtle text-' . $style['tone'] . '-emphasis" title="' . escapeHtml($row['log_action']) . '"><i class="fas fa-fw ' . $style['icon'] . '"></i></span>'
. '<div class="flex-grow-1 min-w-0">'
. '<div class="text-break">' . escapeHtml(auditLogDescription($row)) . '</div>'
. '<div class="small text-body-secondary d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mt-1">' . $who['html']
. '<span title="' . escapeHtml($row['log_created_at']) . '">' . escapeHtml(timeAgo($row['log_created_at'])) . '</span>'
. (!empty($session_is_admin) && empty($opts['compact']) && $ip !== '' ? '<span class="font-monospace" title="' . escapeHtml(auditLogDevice($row['log_user_agent'])) . '">' . escapeHtml($ip) . '</span>' : '')
. '</div></div></li>';
}
$html .= '</ul>';
$more = $history['count'] - count($history['rows']);
$url = auditLogHistoryUrl($history, $opts['client_id'] ?? 0);
if ($more > 0 || $url !== '') {
$html .= '<div class="d-flex flex-wrap align-items-center gap-2 small px-3 py-2 ' . ($flush ? 'border-top' : '') . '" data-audit-history-more>'
. ($more > 0 ? '<span class="text-body-secondary">and ' . number_format($more) . ' more</span>' : '')
. ($url !== '' ? '<a class="ms-auto" href="' . escapeHtml($url) . '"><i class="fas fa-fw fa-history me-1"></i>' . ($history['count'] > 1 ? 'All ' . number_format($history['count']) . ' in the audit log' : 'In the audit log') . '</a>' : '')
. '</div>';
}
return $html;
}
/** A record's history as a card of its own, for a page without tabs - narrow, so compact. $opts as auditLogHistoryHtml(), and 'title'. */
function auditLogHistoryCard(array $history, array $opts = []) {
$opts += ['compact' => true];
return '<div class="card mb-3" data-audit-history-card>'
. '<div class="card-header"><h3 class="card-title mb-0"><i class="fas fa-fw fa-history me-2 text-secondary"></i>' . escapeHtml($opts['title'] ?? 'History')
. ($history['count'] ? ' <span class="text-body-secondary fw-normal">(' . number_format($history['count']) . ')</span>' : '') . '</h3></div>'
. auditLogHistoryHtml($history, $opts)
. '</div>';
}
/*
* A record's history folded away at the foot of its quick look - History
* with its count, opening to the list. Nothing when it has none.
*/
function auditLogHistorySection(array $history, array $opts = []) {
if (!$history['count']) {
return '';
}
$id = 'auditHistory' . bin2hex(random_bytes(4));
return '<div class="mt-3" data-audit-history-section>'
. '<button class="btn btn-link btn-sm p-0 text-decoration-none small fw-semibold text-uppercase text-body-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#' . $id . '" aria-expanded="false" aria-controls="' . $id . '">'
. '<i class="fas fa-fw fa-history me-1"></i>History <span class="badge rounded-pill text-bg-light border fw-normal ms-1">' . number_format($history['count']) . '</span><i class="fas fa-chevron-down ms-2 small"></i></button>'
. '<div class="collapse mt-2" id="' . $id . '">' . auditLogHistoryHtml($history, ['flush' => false] + $opts) . '</div>'
. '</div>';
}