$views['changes']['where'], 'deletions' => $views['deletions']['where'], 'data' => $views['data']['where'], 'passwords' => "log_type = 'Credential' AND log_action IN ('View', 'View TOTP')", 'exports' => "log_action IN ('Export', 'Download')", 'signins' => "$signins AND log_action = 'Success'", 'failed' => "$signins AND log_action IN ('" . implode("', '", SIGN_IN_FAILED) . "')", ]; $sums = function ($alias = '') use ($parts) { $out = []; foreach ($parts as $key => $where) { $out[] = "COALESCE(SUM($where), 0) AS {$alias}$key"; } return implode(', ', $out); }; // Across the period, and the one before $overall = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS entries, COUNT(DISTINCT NULLIF(log_user_id, 0)) AS people, " . $sums() . " FROM logs WHERE $scope AND $in_period")); $before = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS entries, " . $sums() . " FROM logs WHERE $scope AND " . reportRangeSql('log_created_at', $period, true))); $overall = array_map('intval', $overall); $before = array_map('intval', $before); // Over time - changes, data out and, for an admin, sign-ins and failures $buckets = reportBuckets($period); $series = array_fill_keys(['changes', 'data', 'signins', 'failed'], array_fill_keys($buckets['keys'], 0)); $sql = mysqli_query($mysqli, "SELECT " . reportBucketSql('log_created_at', $buckets['unit']) . " AS b, " . $sums() . " FROM logs WHERE $scope AND $in_period GROUP BY b"); while ($row = mysqli_fetch_assoc($sql)) { foreach (array_keys($series) as $key) { if (isset($series[$key][$row['b']])) { $series[$key][$row['b']] = intval($row[$key]); } } } // When people were at it - the hour of the week, ITFlow itself left out $heat = []; $sql = mysqli_query($mysqli, "SELECT WEEKDAY(log_created_at) + 1 AS d, HOUR(log_created_at) AS h, COUNT(*) AS n FROM logs WHERE $scope AND $in_period AND log_user_id > 0 GROUP BY d, h"); while ($row = mysqli_fetch_assoc($sql)) { $heat[intval($row['d'])][intval($row['h'])] = intval($row['n']); } // Out of hours - outside the business days and hours set for SLAs (Admin > SLA), else 9 to 5 on weekdays $sla = getSlaSettings(); $work_days = $sla['business_days'] ?: [1, 2, 3, 4, 5]; $work_start = preg_match('/^\d{2}:\d{2}(:\d{2})?$/', (string) $sla['business_hours_start']) ? substr($sla['business_hours_start'] . ':00', 0, 8) : '09:00:00'; $work_end = preg_match('/^\d{2}:\d{2}(:\d{2})?$/', (string) $sla['business_hours_end']) ? substr($sla['business_hours_end'] . ':00', 0, 8) : '17:00:00'; if ($work_end <= $work_start) { [$work_start, $work_end] = ['09:00:00', '17:00:00']; } $out_of_hours = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $scope AND $in_period AND log_user_id > 0 AND (WEEKDAY(log_created_at) + 1 NOT IN (" . implode(',', array_map('intval', $work_days)) . ") OR TIME(log_created_at) < '$work_start' OR TIME(log_created_at) >= '$work_end')"))[0]); $by_people = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE $scope AND $in_period AND log_user_id > 0"))[0]); // The audit log, for these entries - an admin's, or the client's when one is picked $period_query = ['canned_date' => 'custom', 'dtf' => $period['start'], 'dtt' => $period['end']]; $log_url = function (array $filters) use ($admin, $client_id, $period_query) { if ($admin) { return '/admin/audit_logs.php?' . http_build_query(array_merge($filters, $client_id && !isset($filters['client']) ? ['client' => $client_id] : [], $period_query)); } $client = intval($filters['client'] ?? $client_id); if (!$client) { return ''; } unset($filters['client']); return '/agent/audit_log.php?' . http_build_query(array_merge(['client_id' => $client], $filters, $period_query)); }; // By person - ITFlow itself last $people = []; $sql = mysqli_query($mysqli, "SELECT log_user_id AS id, user_name, user_avatar, user_type, COUNT(*) AS entries, " . $sums() . ", COUNT(DISTINCT NULLIF(log_ip, '')) AS ips, COUNT(DISTINCT NULLIF(log_client_id, 0)) AS clients, MAX(log_created_at) AS last FROM logs LEFT JOIN users ON user_id = log_user_id AND log_user_id > 0 WHERE $scope AND $in_period GROUP BY log_user_id"); // People since deleted are one row, -1, before ITFlow itself $gone_ids = []; while ($row = mysqli_fetch_assoc($sql)) { $id = intval($row['id']); if ($id > 0 && $row['user_name'] === null) { $gone_ids[$id] = true; $id = -1; } if (!isset($people[$id])) { $people[$id] = ['id' => $id, 'person' => $id > 0 ? $row['user_name'] : ($id === 0 ? 'ITFlow itself' : 'People since deleted'), 'avatar' => (string) $row['user_avatar'], 'kind' => $id === 0 ? 'system' : ($id < 0 ? 'gone' : (intval($row['user_type']) === 2 ? 'portal' : 'agent')), 'entries' => 0, 'prev' => 0, 'ips' => 0, 'clients' => 0, 'last' => null, 'count' => 0] + array_fill_keys(array_keys($parts), 0); } $people[$id]['count']++; foreach (array_merge(['entries', 'ips', 'clients'], array_keys($parts)) as $key) { $people[$id][$key] += intval($row[$key]); } $people[$id]['last'] = max((string) $people[$id]['last'], (string) $row['last']); } if (isset($people[-1])) { $people[-1]['person'] = $people[-1]['count'] . ' ' . ($people[-1]['count'] === 1 ? 'person' : 'people') . ' since deleted'; } if ($people) { $sql = mysqli_query($mysqli, "SELECT log_user_id AS id, COUNT(*) AS n FROM logs WHERE $scope AND " . reportRangeSql('log_created_at', $period, true) . " GROUP BY log_user_id"); while ($row = mysqli_fetch_assoc($sql)) { $id = isset($gone_ids[intval($row['id'])]) ? -1 : intval($row['id']); if (isset($people[$id])) { $people[$id]['prev'] += intval($row['n']); } } } foreach ($people as &$row) { $row['change'] = reportChange($row['entries'], $row['prev']); } unset($row); uasort($people, function ($a, $b) { $order = ['agent' => 0, 'portal' => 0, 'gone' => 1, 'system' => 2]; return $order[$a['kind']] <=> $order[$b['kind']] ?: $b['entries'] <=> $a['entries'] ?: strcasecmp($a['person'], $b['person']); }); $people = array_values($people); // By client $clients = []; $sql = mysqli_query($mysqli, "SELECT log_client_id AS id, client_name, client_abbreviation, COUNT(*) AS entries, " . $sums() . ", COUNT(DISTINCT NULLIF(log_user_id, 0)) AS people, MAX(log_created_at) AS last FROM logs LEFT JOIN clients ON client_id = log_client_id WHERE $scope AND $in_period AND log_client_id > 0 GROUP BY log_client_id ORDER BY entries DESC, client_name ASC"); // Clients since deleted are one row, last $gone_clients = null; while ($row = mysqli_fetch_assoc($sql)) { $client = ['id' => intval($row['id']), 'client' => (string) $row['client_name'], 'abbr' => (string) $row['client_abbreviation'], 'gone' => $row['client_name'] === null, 'entries' => intval($row['entries']), 'changes' => intval($row['changes']), 'deletions' => intval($row['deletions']), 'data' => intval($row['data']), 'people' => intval($row['people']), 'last' => $row['last'], 'count' => 1]; if (!$client['gone']) { $clients[] = $client; } elseif ($gone_clients === null) { $gone_clients = $client; } else { foreach (['entries', 'changes', 'deletions', 'data', 'people', 'count'] as $key) { $gone_clients[$key] += $client[$key]; } $gone_clients['last'] = max($gone_clients['last'], $client['last']); } } if ($gone_clients) { $gone_clients['client'] = $gone_clients['count'] . ' client' . ($gone_clients['count'] === 1 ? '' : 's') . ' since deleted'; $clients[] = $gone_clients; } // By kind of record $kinds = []; $sql = mysqli_query($mysqli, "SELECT log_type AS type, COUNT(*) AS entries, SUM(log_action IN ('Create', 'Bulk Create', 'Import', 'Upload')) AS created, SUM(log_action IN ('Edit', 'Bulk Edit', 'Update', 'Modify', 'Rename', 'Move', 'Merge')) AS edited, " . $sums() . ", COUNT(DISTINCT NULLIF(log_entity_id, 0)) AS records, MAX(log_created_at) AS last FROM logs WHERE $scope AND $in_period GROUP BY log_type ORDER BY entries DESC, log_type ASC"); while ($row = mysqli_fetch_assoc($sql)) { $kinds[] = ['type' => (string) $row['type'], 'entries' => intval($row['entries']), 'created' => intval($row['created']), 'edited' => intval($row['edited']), 'deletions' => intval($row['deletions']), 'data' => intval($row['data']), 'records' => intval($row['records']), 'last' => $row['last']]; } // The security events - for an admin $security = []; if ($admin) { $sql = mysqli_query($mysqli, "SELECT log_type AS type, log_action AS action, COUNT(*) AS n, COUNT(DISTINCT NULLIF(log_ip, '')) AS ips, MAX(log_created_at) AS last FROM logs WHERE $scope AND $in_period AND {$views['security']['where']} GROUP BY log_type, log_action ORDER BY n DESC, log_type ASC, log_action ASC"); while ($row = mysqli_fetch_assoc($sql)) { $security[] = ['type' => (string) $row['type'], 'action' => (string) $row['action'], 'n' => intval($row['n']), 'ips' => intval($row['ips']), 'last' => $row['last']]; } } $zero = function ($value, $tone = '') { return $value ? '' . reportCount($value) . '' : '0'; }; $last_cell = function ($row) { return $row['last'] ? '' . escapeHtml(timeAgo($row['last'])) . '' : ''; }; $people_columns = [ 'person' => ['label' => 'Who', 'html' => function ($row) use ($log_url) { if ($row['kind'] === 'system') { $cell = 'ITFlow itselfcron, the API, the email parser'; } elseif ($row['kind'] === 'gone') { return '' . escapeHtml($row['person']) . ''; } else { $cell = reportAgentCell($row['id'], $row['person'], $row['avatar'], 'Someone since deleted') . ($row['kind'] === 'portal' ? ' Portal' : ''); } $url = $log_url(['user' => $row['id'] ?: 'system']); return $url !== '' ? '' . $cell . '' : $cell; }, 'csv' => function ($row) { return $row['person'] . ($row['kind'] === 'portal' ? ' (portal)' : ''); }], 'entries' => ['label' => 'Entries', 'type' => 'count', 'class' => 'fw-semibold'], 'change' => ['label' => 'Change', 'type' => 'percent', 'csv' => false, 'html' => function ($row) { return $row['change'] === null ? '–' : '' . reportChangeHtml($row['entries'], $row['prev'], 'up') . ''; }], 'changes' => ['label' => 'Changes', 'type' => 'count', 'title' => 'Records made, changed, archived, restored and deleted'], 'deletions' => ['label' => 'Deleted', 'type' => 'count', 'title' => 'Records deleted for good', 'html' => function ($row) use ($zero) { return $zero($row['deletions'], 'danger'); }], 'passwords' => ['label' => 'Passwords seen', 'type' => 'count', 'title' => 'Passwords and 2FA codes looked at', 'html' => function ($row) use ($zero) { return $zero($row['passwords'], 'warning'); }], 'exports' => ['label' => 'Exports', 'type' => 'count', 'title' => 'Exports and downloads', 'html' => function ($row) use ($zero) { return $zero($row['exports'], 'warning'); }], 'clients' => ['label' => 'Clients', 'type' => 'count', 'title' => 'Clients they did something for'], ]; if ($admin) { $people_columns['signins'] = ['label' => 'Sign-ins', 'type' => 'count']; $people_columns['failed'] = ['label' => 'Failed', 'type' => 'count', 'title' => 'Failed sign-ins against the account - not always them', 'html' => function ($row) use ($zero) { return $zero($row['failed'], 'danger'); }]; $people_columns['ips'] = ['label' => 'IP addresses', 'type' => 'count', 'title' => 'Places it was done from']; } $people_columns['last'] = ['label' => 'Last', 'type' => 'date', 'html' => $last_cell]; $client_columns = [ 'client' => ['label' => 'Client', 'html' => function ($row) use ($log_url) { if ($row['gone']) { return '' . escapeHtml($row['client']) . ''; } $url = $log_url(['client' => $row['id']]); return reportClientCell($row['id'], $row['client'], $row['abbr'], 'client_overview.php') . ($url !== '' ? ' ' : ''); }], 'entries' => ['label' => 'Entries', 'type' => 'count', 'class' => 'fw-semibold'], 'changes' => ['label' => 'Changes', 'type' => 'count'], 'deletions' => ['label' => 'Deleted', 'type' => 'count', 'html' => function ($row) use ($zero) { return $zero($row['deletions'], 'danger'); }], 'data' => ['label' => 'Data out', 'type' => 'count', 'title' => 'Passwords looked at, exports, downloads and shared links', 'html' => function ($row) use ($zero) { return $zero($row['data'], 'warning'); }], 'people' => ['label' => 'People', 'type' => 'count', 'title' => 'Agents and portal contacts'], 'last' => ['label' => 'Last', 'type' => 'date', 'html' => $last_cell], ]; $kind_columns = [ 'type' => ['label' => 'What', 'html' => function ($row) use ($log_url) { $url = $log_url(['type' => $row['type']]); $label = '' . escapeHtml($row['type']); return $url !== '' ? '' . $label . '' : $label; }], 'entries' => ['label' => 'Entries', 'type' => 'count', 'class' => 'fw-semibold'], 'records' => ['label' => 'Records', 'type' => 'count', 'title' => 'Different records'], 'created' => ['label' => 'Made', 'type' => 'count'], 'edited' => ['label' => 'Changed', 'type' => 'count'], 'deletions' => ['label' => 'Deleted', 'type' => 'count', 'html' => function ($row) use ($zero) { return $zero($row['deletions'], 'danger'); }], 'data' => ['label' => 'Data out', 'type' => 'count', 'html' => function ($row) use ($zero) { return $zero($row['data'], 'warning'); }], 'last' => ['label' => 'Last', 'type' => 'date', 'html' => $last_cell], ]; $security_columns = [ 'what' => ['label' => 'What', 'html' => function ($row) use ($log_url) { $url = $log_url(['view' => 'security', 'type' => $row['type'], 'action' => $row['action']]); return '' . escapeHtml($row['type']) . ' ' . auditLogActionPill($row['action']); }, 'csv' => function ($row) { return $row['type'] . ' ' . $row['action']; }], 'n' => ['label' => 'Times', 'type' => 'count', 'class' => 'fw-semibold'], 'ips' => ['label' => 'IP addresses', 'type' => 'count'], 'last' => ['label' => 'Last', 'type' => 'date', 'html' => $last_cell], ]; $sum = function (array $rows, $key) { return array_sum(array_column($rows, $key)); }; $people_totals = ['person' => 'Total', 'entries' => $overall['entries']] + array_intersect_key($overall, $parts); $client_totals = ['client' => 'Total', 'entries' => $sum($clients, 'entries'), 'changes' => $sum($clients, 'changes'), 'deletions' => $sum($clients, 'deletions'), 'data' => $sum($clients, 'data')]; $kind_totals = ['type' => 'Total', 'entries' => $overall['entries'], 'created' => $sum($kinds, 'created'), 'edited' => $sum($kinds, 'edited'), 'deletions' => $overall['deletions'], 'data' => $overall['data']]; $name = $period['start'] . ' to ' . $period['end']; if (reportExporting('clients')) { reportCsv("Audit Activity by Client $name", $client_columns, $clients, $client_totals); } if (reportExporting('kinds')) { reportCsv("Audit Activity by Kind $name", $kind_columns, $kinds, $kind_totals); } if ($admin && reportExporting('security')) { reportCsv("Audit Activity Security $name", $security_columns, $security, ['what' => 'Total', 'n' => $sum($security, 'n')]); } if (reportExporting()) { reportCsv("Audit Activity by Person $name", $people_columns, $people, $people_totals); } require_once "includes/inc_report_layout.php"; // Years with entries, back to the oldest kept $past_years = []; $oldest = mysqli_fetch_row(mysqli_query($mysqli, "SELECT YEAR(MIN(log_created_at)) FROM logs"))[0] ?? null; for ($y = intval(date('Y')) - 2; $oldest && $y >= intval($oldest); $y--) { $past_years[] = $y; } $retention = appLogRetention(); $exports = ['' => 'By person', 'clients' => 'By client', 'kinds' => 'By kind'] + ($admin ? ['security' => 'Security events'] : []); $stats = [ ['label' => 'Changes', 'value' => reportCount($overall['changes']), 'sub' => ($before['changes'] || $overall['changes'] ? reportChangeHtml($overall['changes'], $before['changes'], 'up', 'the period before') . ' · ' : '') . $overall['people'] . ' ' . ($overall['people'] === 1 ? 'person' : 'people') . ''], ['label' => 'Deleted', 'value' => reportCount($overall['deletions']), 'tone' => $overall['deletions'] ? 'danger' : '', 'sub' => $before['deletions'] || $overall['deletions'] ? reportChangeHtml($overall['deletions'], $before['deletions'], 'down', 'the period before') : 'Nothing deleted'], ['label' => 'Data out', 'value' => reportCount($overall['data']), 'tone' => $overall['data'] ? 'warning' : '', 'sub' => reportCount($overall['passwords']) . ' password' . ($overall['passwords'] === 1 ? '' : 's') . ' seen · ' . reportCount($overall['exports']) . ' export' . ($overall['exports'] === 1 ? '' : 's') . ''], ]; if ($admin) { $stats[] = ['label' => 'Sign-ins', 'value' => reportCount($overall['signins']), 'tone' => $overall['failed'] > $overall['signins'] ? 'danger' : '', 'sub' => $overall['failed'] ? '' . reportCount($overall['failed']) . ' failed' : 'None failed']; } else { $stats[] = ['label' => 'Out of hours', 'value' => $by_people ? reportPercent(reportShare($out_of_hours, $by_people)) : '-', 'sub' => reportCount($out_of_hours) . ' of ' . reportCount($by_people) . ' entries']; } $chart_series = [ ['label' => 'Changes', 'tone' => 'accent', 'data' => array_values($series['changes']), 'stack' => 'a'], ['label' => 'Data out', 'tone' => 'warning', 'data' => array_values($series['data']), 'stack' => 'a'], ]; if ($admin) { $chart_series[] = ['label' => 'Sign-ins', 'tone' => 'info', 'data' => array_values($series['signins']), 'stack' => 'a']; $chart_series[] = ['label' => 'Failed sign-ins', 'tone' => 'danger', 'data' => array_values($series['failed']), 'stack' => 'a']; } ?>