Files
itflow/functions/audit_log.php
johnnyq 3b5c11c552 Add managed mode, so one ITFlow can manage others - Admin > Managed Instances adds an instance and gives a one-time connection code (a week) that the other ITFlow pastes into the new Admin > Managed By, after which it checks in every minute from the new Manager Check-in cron job with a health report (versions, update waiting and whether it has security fixes, cron, last backup, errors today, agents against the seat limit, clients, storage) and takes back what the manager sets; the manager lists each instance's version, health, seats and last check-in with a page per instance showing what needs a look, its requests and everything it reported, can update it by hand or automatically after a set number of days (security releases straight away, not retried for six hours after a failure), set its seat limit and white-label key (locked on the instance while set), make it read-only (look, export and download only, across agents, portal, guest links and the API) or suspended (a notice with the manager's message and contact, 503 for the API) with other cron jobs paused until it is active again, sign in to it as support through a signed one-time two-minute link into a two-hour session that takes no seat, can't open the vault and can't set passwords or make API keys, and remove it, while the new Managed Instances job notifies when one stops checking in and closes requests never answered; instances only call the manager, can disconnect unless config.php sets $config_manager_required, and the Update page and queue_update defer to the manager; database 3.5.8 adds managed_instances, managed_instance_commands, settings.config_manager_keys and settings.config_managed, the logic is in the new functions/managed.php with api/managed/enroll.php, api/managed/checkin.php, login_support.php and includes/managed_blocked.php, cronJobClaim() takes a paused reason that leaves a due job due, getAgentUserLimit() and getActiveAgentUserCount() honour the manager's limit and leave out the support user, the vault helpers read a missing session key as empty, the audit log knows the new types and actions, and the email parser no longer logs an App Logs error every minute while parsing is switched off; changelog updated
2026-11-05 15:22:41 -05:00

491 lines
34 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'],
'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.
*/
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. */
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>';
}