API Keys): each acts as an agent, with that * agent's role and client access, until the day it expires. A key is only * as good as its agent - a disabled, archived or role-less one turns every * call away (api/v1/enforce_api_rbac.php). * * Shared by admin/api_keys.php, admin/modals/api/* and admin/post/api_keys.php. */ /* One key - the row with its agent's name, picture, type, status, archived and role - or null. Admin > API Keys lists them the same way. */ function apiKeyRow($api_key_id) { global $mysqli; $api_key_id = intval($api_key_id); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT api_key_id, api_key_name, api_key_secret, api_key_created_at, api_key_expire, api_key_user_id, api_key_last_used_at, user_name, user_avatar, user_type, user_status, user_archived_at, user_role_id FROM api_keys LEFT JOIN users ON user_id = api_key_user_id WHERE api_key_id = $api_key_id")); return $row ?: null; } /* Has this key expired? It works until the start of its expiry date. */ function apiKeyExpired(array $row) { return (string) $row['api_key_expire'] <= date('Y-m-d'); } /* * Why a key's agent turns every call away - '' when they don't: no agent, * one who is disabled or archived, or one with no role. $role is their * roleRow() (or null). */ function apiKeyAgentProblem(array $row, $role) { $name = trim(str_replace(' (archived)', '', (string) ($row['user_name'] ?? ''))); if (!intval($row['api_key_user_id']) || $row['user_name'] === null || intval($row['user_type']) !== 1) { return 'it acts as nobody - pick an agent'; } if ($row['user_archived_at']) { return "$name is archived"; } if (intval($row['user_status']) !== 1) { return "$name is disabled"; } if (!$role) { return "$name has no role"; } return ''; } /* * A key's state as a pill - whether it works, and until when. Expired; * won't work because of its agent; expiring within two weeks; or working. * HTML. */ function apiKeyStatePill(array $row, $role) { $expire = date('M j, Y', strtotime($row['api_key_expire'])); if (apiKeyExpired($row)) { return 'Expired'; } $problem = apiKeyAgentProblem($row, $role); if ($problem !== '') { return 'Won\'t work - ' . escapeHtml($problem) . ''; } $days = (int) floor((strtotime($row['api_key_expire']) - strtotime(date('Y-m-d'))) / 86400); if ($days <= 14) { return 'Expires in ' . $days . ' day' . ($days === 1 ? '' : 's') . ''; } return 'Works until ' . $expire . ''; } /* The agents a key can act as - active ones, by name: [user_id => row with role_id]. */ function apiKeyAgents() { global $mysqli; $agents = []; $sql = mysqli_query($mysqli, "SELECT user_id, user_name, user_avatar, user_role_id FROM users WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL ORDER BY user_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $agents[intval($row['user_id'])] = $row; } return $agents; } /* * The New Key / Edit Key window's fields, checked - [fields, error]. * fields: name, expire (Y-m-d, after today - or the day it already has, so * an expired key can be renamed and stay expired) and user_id (an active * agent). */ function apiKeyFromInput(array $in, $current_expire = '') { $name = trim(mb_substr(strip_tags((string) ($in['name'] ?? '')), 0, 255)); if ($name === '') { return [null, 'A key needs a name.']; } $expire = (string) ($in['expire'] ?? ''); $date = DateTime::createFromFormat('!Y-m-d', $expire); if (!$date || $date->format('Y-m-d') !== $expire || $expire > '2999-12-31' || ($expire <= date('Y-m-d') && $expire !== (string) $current_expire)) { return [null, 'Pick the day it stops working - after today.']; } $user_id = intval($in['run_as_user'] ?? 0); if (!isset(apiKeyAgents()[$user_id])) { return [null, 'Pick an active agent for it to act as.']; } return [['name' => $name, 'expire' => $expire, 'user_id' => $user_id], '']; } /* * The "Act as" picker of the key windows, with what each agent can open * shown under it for the one picked (js in api_key_form). $selected: the * user_id, 0 for none. */ function apiKeyAgentFieldHtml($selected = 0) { $selected = intval($selected); $agents = apiKeyAgents(); $roles = roleRows(); $access = userClientAccess(array_keys($agents)); ob_start(); ?>
$agent) { $role = $roles[intval($agent['user_role_id'])] ?? null; ?>
>
Their role, ยท [], 'deny' => []], intval($role['role_is_admin']))) ?>
They have no role, so the key would be turned away from everything.
The key can do what they can - their role and the clients they see - and calls show in the audit log as them.
getMessage()); } } /* * Each key's use over the last $days days - [api_key_id => ['calls', * 'endpoints', 'last_at', 'last_ip']], nothing for a key not used. */ function apiKeyUsageSummary(array $api_key_ids, $days = 30) { global $mysqli; $ids = array_values(array_filter(array_map('intval', $api_key_ids))); $usage = []; if (!$ids) { return $usage; } $days = max(1, intval($days)); $sql = mysqli_query($mysqli, "SELECT api_key_usage_key_id, SUM(api_key_usage_calls) AS calls, COUNT(DISTINCT api_key_usage_endpoint) AS endpoints, MAX(api_key_usage_last_at) AS last_at FROM api_key_usage WHERE api_key_usage_key_id IN (" . implode(',', $ids) . ") AND api_key_usage_date > CURDATE() - INTERVAL $days DAY GROUP BY api_key_usage_key_id"); while ($row = mysqli_fetch_assoc($sql)) { $usage[intval($row['api_key_usage_key_id'])] = ['calls' => intval($row['calls']), 'endpoints' => intval($row['endpoints']), 'last_at' => $row['last_at'], 'last_ip' => '']; } // Where the last call came from - the row it was counted on $sql = mysqli_query($mysqli, "SELECT u.api_key_usage_key_id, u.api_key_usage_last_ip FROM api_key_usage u JOIN (SELECT api_key_usage_key_id, MAX(api_key_usage_last_at) AS last_at FROM api_key_usage WHERE api_key_usage_key_id IN (" . implode(',', $ids) . ") GROUP BY api_key_usage_key_id) m ON m.api_key_usage_key_id = u.api_key_usage_key_id AND m.last_at = u.api_key_usage_last_at"); while ($row = mysqli_fetch_row($sql)) { if (isset($usage[intval($row[0])])) { $usage[intval($row[0])]['last_ip'] = (string) $row[1]; } } return $usage; } /* A key's calls each day over the last $days days, oldest first - [Y-m-d => calls], every day there. */ function apiKeyUsageDaily($api_key_id, $days = 30) { global $mysqli; $days = max(1, intval($days)); $daily = []; for ($i = $days - 1; $i >= 0; $i--) { $daily[date('Y-m-d', strtotime("-$i days"))] = 0; } $sql = mysqli_query($mysqli, "SELECT api_key_usage_date, SUM(api_key_usage_calls) FROM api_key_usage WHERE api_key_usage_key_id = " . intval($api_key_id) . " AND api_key_usage_date > CURDATE() - INTERVAL $days DAY GROUP BY api_key_usage_date"); while ($row = mysqli_fetch_row($sql)) { if (isset($daily[$row[0]])) { $daily[$row[0]] = intval($row[1]); } } return $daily; } /* The endpoints a key called over the last $days days, busiest first - rows of endpoint, calls, last_at. */ function apiKeyUsageEndpoints($api_key_id, $days = 30) { global $mysqli; $rows = []; $sql = mysqli_query($mysqli, "SELECT api_key_usage_endpoint AS endpoint, SUM(api_key_usage_calls) AS calls, MAX(api_key_usage_last_at) AS last_at FROM api_key_usage WHERE api_key_usage_key_id = " . intval($api_key_id) . " AND api_key_usage_date > CURDATE() - INTERVAL " . max(1, intval($days)) . " DAY GROUP BY api_key_usage_endpoint ORDER BY calls DESC, endpoint ASC"); while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } return $rows; } /* The IP addresses a key was last called from on each day, over the last $days days - rows of ip and last_at, latest first. */ function apiKeyUsageIps($api_key_id, $days = 30) { global $mysqli; $rows = []; $sql = mysqli_query($mysqli, "SELECT api_key_usage_last_ip AS ip, MAX(api_key_usage_last_at) AS last_at FROM api_key_usage WHERE api_key_usage_key_id = " . intval($api_key_id) . " AND api_key_usage_date > CURDATE() - INTERVAL " . max(1, intval($days)) . " DAY AND api_key_usage_last_ip <> '' GROUP BY api_key_usage_last_ip ORDER BY last_at DESC LIMIT 10"); while ($row = mysqli_fetch_assoc($sql)) { $rows[] = $row; } return $rows; } /* Calls turned away with a key after it expired, over the last $days days - ['count', 'last_at']. */ function apiKeyTurnedAway($api_key_id, $days = 30) { global $mysqli; $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), MAX(log_created_at) FROM logs WHERE log_type = 'API' AND log_action = 'Failed' AND log_entity_id = " . intval($api_key_id) . " AND log_created_at > NOW() - INTERVAL " . max(1, intval($days)) . " DAY")); return ['count' => intval($row[0]), 'last_at' => $row[1]]; } /* * After an API call is turned away: when this IP address has just reached * the admins' number of them in SIGN_IN_WINDOW_MINUTES, tell them * (signInAlertAdmins()). */ function apiKeyWatch() { global $mysqli, $session_ip; $limit = signInAlertLimit(); $ip = escapeSql((string) $session_ip); if ($limit <= 0 || $ip === '') { return; } $count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM logs WHERE log_type = 'API' AND log_action = 'Failed' AND log_ip = '$ip' AND log_created_at > NOW() - INTERVAL " . SIGN_IN_WINDOW_MINUTES . " MINUTE"))[0]); if ($count === $limit) { signInAlertAdmins( "$limit API calls turned away from $session_ip", "$limit API calls in " . SIGN_IN_WINDOW_MINUTES . " minutes from $session_ip were turned away - with a key that is wrong or has expired.", ['IP address' => (string) $session_ip], '/admin/audit_logs.php?view=security&q=' . rawurlencode((string) $session_ip) ); } }