diff --git a/CHANGELOG.md b/CHANGELOG.md index d4dcbafae..2ed897a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.6) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.7) in the same pass. Nothing else is required. -This is a big release, with thirty-six database steps in one go, so take a backup before you update. +This is a big release, with thirty-seven database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -37,6 +37,8 @@ Tags keep their names, colors, icons and records. A color saved as a word, such Categories keep their names, colors, icons, order and records. A color saved as a word, such as lightblue or gray, becomes the same color written as #rrggbb, and a short one such as #abc is written out in full, so nothing looks different, but the ticket lists and the reports, which only read colors written that way, now show it (see Bug Fixes). A color that isn't one is taken off, and a category with no color stays without one. Interface types, asset statuses, software types, rack types and note types keep the order they are offered in. +API keys keep their names, end dates and the agent they act as. Admin > API Keys shows each as never used until its next call. A key acting as an agent who is disabled or archived, or has no role, was already turned away by the API, and is now marked Won't work with the reason (see New Features, API Keys). + If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. ### Breaking Changes and Notes @@ -184,6 +186,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Domains: an update no longer wipes the expiry date and DNS records when the lookup comes back empty. A domain name the client already has is refused. - Deleting an asset, contact, client or location through the API now also cleans up whatever pointed at it, the same as deleting it in ITFlow. - New endpoint to read services, under the Support permission. Vendors take the account manager's phone and email. + - A call the key's role doesn't allow is refused with what it needs and what the key's user has - "This needs Modify on Support - the key's user, Tess Tech (Technician), has Read." - in place of "The user linked to this API key does not have permission for this action." It is still a 403. Anything that matches on the old text needs changing. ### New Features & Updates @@ -818,6 +821,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Agents aren't offered what their role can't do: the Clients and Assets bulk menus at Read, Delete among archived assets below Full, editing an asset's recurring tickets at Read, and links into a report for an agent who can't open it. - The role window warns when a role is given Support, Credentials, Sales or Financial with Clients at None, as each client's pages need Clients. - A user's quick look shows what they can open, from their role, and the user form shows what the role picked opens. + - API: read the roles - an admin key every role, with its level of each part of ITFlow and how many agents have it, archived ones too; any other key only its own user's role. Read the agents, with their role, under the Support permission. + - API: a call the key's role doesn't allow says what it needs and what the key's user has, with how to get it changed (see Breaking Changes, API). +- **API Keys** (rebuilt) + - Admin > API Keys shows, for each key, the agent it acts as and their role, what it can open and for which clients, whether it works and when it was last used. Whether it works reads Works until a day, Expires in so many days in its last two weeks, Expired, or Won't work with why: its agent is disabled, archived or has no role, or it acts as nobody. Search finds keys by their name or their agent's. + - The New API Key and Edit windows show what the picked agent can open as you pick them, and offer only active agents. A new key lasts a year unless you change it, and its last day has to be after today. An expired key can be renamed and stays expired; a later day brings it back. + - Create on the Details tab before ticking that the key and password were copied opens the Key tab, rather than doing nothing. + - Revoke ends a key today. Delete is for expired keys, as before, one at a time or several from the bulk menu. + - The audit log says who a new key acts as and when it ends, and what an edit changed: "renamed from RMM, expiring 2027-06-01 (was 2026-12-01), acting as Tess Tech (was Johnny Test)". ### Bug Fixes @@ -915,6 +926,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Roles: a role's name went into the "created" and "edited" messages as it was typed, so markup in it ran on the page. - Roles: an admin role could have admin taken off while its agents were the admins using it, your own included, shutting them out of Admin. Archived roles couldn't be seen or brought back. - Roles: being turned away left "You are not permitted to do that!" waiting on the next page opened. +- API keys: revoking, deleting and bulk deleting keys showed and logged no name ("API Key revoked"), and the bulk message counted the keys ticked rather than those deleted. A name with an apostrophe showed with a backslash in the created and updated messages. +- API keys: a new key could be given today as its last day, so it never worked. An expired key couldn't be renamed without giving it a new day, as Edit wouldn't save the one it had. +- API: reading an agent's time (`technicians/time.php`) needed Modify on Support, though it only reads. - Contacts: an agent without Support saw a contact's Assets, Documents, Files and Services tabs, which led to pages they were refused. A document's or file's linked assets, licenses, services, domains and certificates were listed to them too. - All Client Docs listed Credentials to agents who can't open it. - Notifications: shared-link notifications reached agents without Support, with a link they couldn't open. @@ -1328,6 +1342,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `LINK_ENTITIES` has a `view` module for asset, software, service, domain and certificate (Support) and contact and vendor (Clients), so `linkTypesFor()` leaves them out for an agent without it. `notificationTypeModules()` files `Shared link opened`, `Shared link locked`, `Login sent back` and `Share Viewed` under Support. - `agent/includes/inc_client_top_head.php` includes `modals/client/client_delete.php` only where Delete Client is in the menu. `agent/includes/client_overview_side_nav.php` checks Credentials. - The role window marks each module `data-role-module="module_name"` and has `data-role-needs-clients`; the user form has a `data-user-role-access` block per role. +- API Keys (database 3.1.7): + - The migration adds `api_keys.api_key_last_used_at` (DATETIME, null). `api/v1/validate_api_key.php` sets it on a call that gets past the key check, at most once a minute, in `config_timezone`, as the API doesn't set PHP's or MySQL's clock to it. + - New `functions/api_keys.php`, loaded by `functions.php`: `apiKeyRows($search)` and `apiKeyRow($id)` (the key with its agent's `user_name`, `user_avatar`, `user_type`, `user_status`, `user_archived_at` and `user_role_id`), `apiKeyExpired($row)`, `apiKeyAgentProblem($row, $role)`, `apiKeyStatePill($row, $role)`, `apiKeyAgents()` (active agents), `apiKeyFromInput($in, $current_expire)` (`[$fields, $error]`: `name`, `expire`, `user_id`) and `apiKeyAgentFieldHtml($selected)` (the `run_as_user` picker, with a `data-api-key-access` block per agent). + - Admin: `admin/api_keys.php` is rebuilt (`q`; no paging or sorting). `admin/post/api_keys.php`: `add_api_key` (`name`, `expire`, `run_as_user`, `key` - 32 of `A-Za-z0-9_-` - and `password`), `edit_api_key` (`api_key_id`, `name`, `expire`, `run_as_user`), GET `revoke_api_key` (`api_key_expire` to today) and `delete_api_key`, and `bulk_delete_api_keys` (`api_key_ids[]`). Each checks the key exists, and add and edit take an active agent only. Audit entries are API Key Create, Edit (with what changed, and only when something did), Revoke and Delete, each linked to its key. The Bulk Delete summary entry is gone, as each key logs its own Delete. + - API: `api/v1/roles/read.php` (GET; `role_id`, `archived`, `limit`, `offset`) answers `role_id`, `role_name`, `role_description`, `role_is_admin`, `role_levels` (`module_name` => 0 to 3), `role_agents`, `role_created_at`, `role_updated_at` and `role_archived_at`. A key whose user isn't an admin gets only `$session_user_role`. `enforce_api_rbac.php` maps `roles` to `''`. `api/v1/technicians/read.php` (GET, `module_support`; `technician_id`, `archived`, `limit`, `offset`) answers `technician_id`, `technician_name`, `technician_email`, `technician_status`, `role_id`, `role_name`, `role_is_admin`, `technician_created_at` and `technician_archived_at`. + - `enforce_api_rbac.php`: `apiPermissionText($module, $level)` words the module refusal, and `$operation_level` maps `time.php` to 1. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/api_keys.php b/admin/api_keys.php index 0d1db9a6e..2aa01540d 100644 --- a/admin/api_keys.php +++ b/admin/api_keys.php @@ -1,20 +1,21 @@ API Keys - the keys other systems use the API with. Each acts as + * an agent, so each shows who, what their role opens and which clients + * they see, whether the key works (it expires, and an agent who is + * disabled, archived or has no role turns every call away) and when it was + * last used. The work is in functions/api_keys.php; the handlers are + * admin/post/api_keys.php. + */ require_once "includes/inc_all_admin.php"; -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS api_key_created_at, api_key_expire, api_key_id, api_key_name, api_key_secret, user_name FROM api_keys - LEFT JOIN users on api_key_user_id = user_id - WHERE (api_key_name LIKE '%$q%') - ORDER BY $sort $order LIMIT $record_from, $record_to" -); - -$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +$search = trim((string) ($_GET['q'] ?? '')); +$keys = apiKeyRows($search); +$roles = roleRows(); +$access = userClientAccess(array_filter(array_map('intval', array_column($keys, 'api_key_user_id')))); +$csrf = $_SESSION['csrf_token']; ?> @@ -26,149 +27,120 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); -
- -
-
- -
-
- - -
-
- -
-
- -
-
- +
+

A key lets another system use ITFlow's API as one of your agents - it can do what their role allows, for the clients they see, until it expires.

+ +
+ + +
+ -
-
- -
- - - - "> - - - - - - - - - - - - None"; - - ?> + + +
+
-
- -
-
- - Name - - - - User - - - - Secret - - - - Created - - - - Expires - - Action
+ + - + + + + + + + + + + $row) { + $name = escapeHtml($row['api_key_name']); + $role = $roles[intval($row['user_role_id'])] ?? null; + $expired = apiKeyExpired($row); + $agent_name = trim(str_replace(' (archived)', '', (string) $row['user_name'])); + $last_used = $row['api_key_last_used_at'] ? '' . escapeHtml(timeAgo($row['api_key_last_used_at'])) . '' : 'Never'; + ob_start(); ?> + +
+ +
+
+
+
+
+ + Nobody + ' . escapeHtml(userClientAccessText($access[intval($row['api_key_user_id'])] ?? ['allow' => [], 'deny' => []], intval($role['role_is_admin']))) . '' : 'Nothing'; + ?> + + - - - - - + + + + - - -
-
- +
+
+ +
+
KeyActs asCan openLast usedAction
+
+
+ +
•••• · made
+
+ +
+
+
Last used
+
+
+ - - -
- 'API keys', - 'icon' => 'fa-key', - 'text' => 'A key lets another system read and write ITFlow through its API.', - 'actions' => [['label' => 'New API Key', 'modal' => 'modals/api/api_key_add.php']], - ]; - ?> - + + 'API keys', + 'icon' => 'fa-key', + 'text' => 'A key lets another system read and write ITFlow through its API, as one of your agents.', + 'filtered' => $search !== '', + 'clear_url' => 'api_keys.php', + 'actions' => [['label' => 'New API Key', 'modal' => 'modals/api/api_key_add.php']], + ]) ?> +
diff --git a/admin/database_updates/3.1.7.php b/admin/database_updates/3.1.7.php new file mode 100644 index 000000000..9c437bbb5 --- /dev/null +++ b/admin/database_updates/3.1.7.php @@ -0,0 +1,11 @@ + API Keys, to find the ones nothing uses + mysqli_query($mysqli, "ALTER TABLE `api_keys` ADD `api_key_last_used_at` DATETIME NULL DEFAULT NULL AFTER `api_key_user_id`"); diff --git a/admin/modals/api/api_key_add.php b/admin/modals/api/api_key_add.php index 653a197d6..b8d656c3c 100644 --- a/admin/modals/api/api_key_add.php +++ b/admin/modals/api/api_key_add.php @@ -1,5 +1,11 @@
+ + + +
+ +if (!$row) { ?> + +
+ +
- API Keys) - making, changing, revoking and deleting + * them. The checks are in functions/api_keys.php. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); @@ -10,21 +11,29 @@ if (isset($_POST['add_api_key'])) { validateCSRFToken(); - $name = escapeSql($_POST['name']); - $expire = escapeSql($_POST['expire']); - $user_id = intval($_POST['run_as_user']); - $secret = escapeSql($_POST['key']); // API Key + [$fields, $error] = apiKeyFromInput($_POST); + $secret = (string) ($_POST['key'] ?? ''); + $password = trim((string) ($_POST['password'] ?? '')); + if ($error === '' && (!preg_match('/^[A-Za-z0-9_-]{32}$/', $secret) || $password === '')) { + $error = 'The window was missing its key - open New API Key again.'; + } + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } // Credential decryption password - $apikey_specific_encryption_ciphertext = encryptUserSpecificKey(trim($_POST['password'])); + $apikey_specific_encryption_ciphertext = encryptUserSpecificKey($password); - mysqli_query($mysqli,"INSERT INTO api_keys SET api_key_name = '$name', api_key_secret = '$secret', api_key_decrypt_hash = '$apikey_specific_encryption_ciphertext', api_key_expire = '$expire', api_key_user_id = $user_id"); + mysqli_query($mysqli, "INSERT INTO api_keys SET api_key_name = '" . escapeSql($fields['name']) . "', api_key_secret = '" . escapeSql($secret) . "', + api_key_decrypt_hash = '$apikey_specific_encryption_ciphertext', api_key_expire = '" . escapeSql($fields['expire']) . "', api_key_user_id = " . intval($fields['user_id'])); $api_key_id = mysqli_insert_id($mysqli); + $agent = apiKeyAgents()[$fields['user_id']]['user_name']; - logAudit("API Key", "Create", "$session_name created API key $name set to expire on $expire", 0, $api_key_id); + logAudit("API Key", "Create", "$session_name created API key " . escapeSql($fields['name']) . " acting as " . escapeSql($agent) . ", expiring " . $fields['expire'], 0, $api_key_id); - flashAlert("API Key $name created"); + flashAlert("API key " . escapeHtml($fields['name']) . " created"); redirect(); @@ -34,16 +43,38 @@ if (isset($_POST['edit_api_key'])) { validateCSRFToken(); - $api_key_id = intval($_POST['api_key_id']); - $name = escapeSql($_POST['name']); - $expire = escapeSql($_POST['expire']); - $user_id = intval($_POST['run_as_user']); + $row = apiKeyRow($_POST['api_key_id'] ?? 0); + if (!$row) { + flashAlert("That key is gone - it may have been deleted", 'error'); + redirect(); + } + $api_key_id = intval($row['api_key_id']); - mysqli_query($mysqli,"UPDATE api_keys SET api_key_name = '$name', api_key_expire = '$expire', api_key_user_id = $user_id WHERE api_key_id = $api_key_id"); + [$fields, $error] = apiKeyFromInput($_POST, $row['api_key_expire']); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } - logAudit("API Key", "Edit", "$session_name edited API key $name", 0, $api_key_id); + mysqli_query($mysqli, "UPDATE api_keys SET api_key_name = '" . escapeSql($fields['name']) . "', api_key_expire = '" . escapeSql($fields['expire']) . "', + api_key_user_id = " . intval($fields['user_id']) . " WHERE api_key_id = $api_key_id"); - flashAlert("API Key $name updated"); + // What changed, for the audit log + $changes = []; + if ($row['api_key_name'] !== $fields['name']) { + $changes[] = 'renamed from ' . $row['api_key_name']; + } + if ((string) $row['api_key_expire'] !== $fields['expire']) { + $changes[] = 'expiring ' . $fields['expire'] . ' (was ' . $row['api_key_expire'] . ')'; + } + if (intval($row['api_key_user_id']) !== $fields['user_id']) { + $changes[] = 'acting as ' . apiKeyAgents()[$fields['user_id']]['user_name'] . ' (was ' . ($row['user_name'] ?? 'nobody') . ')'; + } + if ($changes) { + logAudit("API Key", "Edit", "$session_name edited API key " . escapeSql($fields['name']) . " - " . escapeSql(implode(', ', $changes)), 0, $api_key_id); + } + + flashAlert("API key " . escapeHtml($fields['name']) . " " . ($changes ? 'saved' : 'unchanged')); redirect(); @@ -53,17 +84,18 @@ if (isset($_GET['revoke_api_key'])) { validateCSRFToken(); - $api_key_id = intval($_GET['revoke_api_key']); + $row = apiKeyRow($_GET['revoke_api_key']); + if (!$row) { + flashAlert("That key is gone - it may have been deleted", 'error'); + redirect(); + } - // Get API Key Name - $row = mysqli_fetch_assoc(mysqli_query($mysqli,"SELECT api_key_name FROM api_keys WHERE api_key_id = $api_key_id")); - $api_key_name = escapeSql($row['api_key_name']); + // Expires today - it works until the start of its expiry day + mysqli_query($mysqli, "UPDATE api_keys SET api_key_expire = CURDATE() WHERE api_key_id = " . intval($row['api_key_id'])); - mysqli_query($mysqli,"UPDATE api_keys SET api_key_expire = NOW() WHERE api_key_id = $api_key_id"); + logAudit("API Key", "Revoke", "$session_name revoked API key " . escapeSql($row['api_key_name']), 0, $row['api_key_id']); - logAudit("API Key", "Revoke", "$session_name revoked API key $name", 0); - - flashAlert("API Key $name revoked", 'error'); + flashAlert("API key " . escapeHtml($row['api_key_name']) . " revoked", 'error'); redirect(); @@ -73,17 +105,17 @@ if (isset($_GET['delete_api_key'])) { validateCSRFToken(); - $api_key_id = intval($_GET['delete_api_key']); + $row = apiKeyRow($_GET['delete_api_key']); + if (!$row) { + flashAlert("That key is gone - it may have been deleted", 'error'); + redirect(); + } - // Get API Key Name - $row = mysqli_fetch_assoc(mysqli_query($mysqli,"SELECT api_key_name FROM api_keys WHERE api_key_id = $api_key_id")); - $api_key_name = escapeSql($row['api_key_name']); + mysqli_query($mysqli, "DELETE FROM api_keys WHERE api_key_id = " . intval($row['api_key_id'])); - mysqli_query($mysqli,"DELETE FROM api_keys WHERE api_key_id = $api_key_id"); + logAudit("API Key", "Delete", "$session_name deleted API key " . escapeSql($row['api_key_name']), 0, $row['api_key_id']); - logAudit("API Key", "Delete", "$session_name deleted API key $name", 0); - - flashAlert("API Key $name deleted", 'error'); + flashAlert("API key " . escapeHtml($row['api_key_name']) . " deleted", 'error'); redirect(); @@ -93,31 +125,19 @@ if (isset($_POST['bulk_delete_api_keys'])) { validateCSRFToken(); - if (isset($_POST['api_key_ids'])) { - - $count = count($_POST['api_key_ids']); - - // Cycle through array and delete each record - foreach ($_POST['api_key_ids'] as $api_key_id) { - - $api_key_id = intval($api_key_id); - - // Get API Key Name - $row = mysqli_fetch_assoc(mysqli_query($mysqli,"SELECT api_key_name FROM api_keys WHERE api_key_id = $api_key_id")); - $api_key_name = escapeSql($row['api_key_name']); - - mysqli_query($mysqli, "DELETE FROM api_keys WHERE api_key_id = $api_key_id"); - - logAudit("API Key", "Delete", "$session_name deleted API key $name", 0); - + $count = 0; + foreach (array_unique(array_map('intval', (array) ($_POST['api_key_ids'] ?? []))) as $api_key_id) { + $row = apiKeyRow($api_key_id); + if (!$row) { + continue; } - - logAudit("API Key", "Bulk Delete", "$session_name deleted $count API key(s)"); - - flashAlert("Deleted $count API keys(s)", 'error'); - + mysqli_query($mysqli, "DELETE FROM api_keys WHERE api_key_id = $api_key_id"); + logAudit("API Key", "Delete", "$session_name deleted API key " . escapeSql($row['api_key_name']), 0, $api_key_id); + $count++; } + flashAlert("Deleted $count API key" . ($count === 1 ? '' : 's'), 'error'); + redirect(); } diff --git a/api/v1/enforce_api_rbac.php b/api/v1/enforce_api_rbac.php index 89964e911..b18ddbd16 100644 --- a/api/v1/enforce_api_rbac.php +++ b/api/v1/enforce_api_rbac.php @@ -33,6 +33,18 @@ function apiDeny($message) { exit(); } +// Why the key's user was refused a module at a level - what it needs, and what their role has. +// Plain text, for apiDeny(). +function apiPermissionText($module, $level) { + global $api_user; + $label = roleModuleInfo()[$module]['label'] ?? ucfirst(str_replace('module_', '', (string) $module)); + $level = max(1, min(3, intval($level))); + $has = intval(lookupUserPermission($module)); + $role = permissionRoleName(); + return "This needs " . ROLE_LEVELS[$level] . " on $label - the key's user, " . ($api_user['user_name'] ?? 'its user') + . ($role !== '' ? " ($role)" : '') . ", has " . ($has ? ROLE_LEVELS[min(3, $has)] : 'no access') . ". An admin can change their role in Admin > Roles."; +} + // Can the signed-in (key's) user access this single client? Mirrors enforceClientAccess: // admin -> yes; explicit deny -> no; otherwise allowed if in the allow list or the // user has no allow list (unrestricted). @@ -154,6 +166,8 @@ $resource_module = [ 'tags' => '', // Likewise each kind of category - api/v1/categories/ checks the kind (categoryApiKind()) 'categories' => '', + // Roles: an admin key reads them all, any other its own user's - api/v1/roles/read.php decides + 'roles' => '', ]; // Operation -> required permission level (read = 1, create/update = 2, delete = 3). @@ -162,6 +176,8 @@ $operation_level = [ 'create.php' => 2, 'update.php' => 2, 'delete.php' => 3, + // technicians/time.php only reads + 'time.php' => 1, ]; if (!isset($resource_module[$resource])) { @@ -173,7 +189,7 @@ if (!isset($resource_module[$resource])) { $required_level = $operation_level[$operation_file] ?? 2; if ($resource_module[$resource] !== '' && lookupUserPermission($resource_module[$resource]) < $required_level) { - apiDeny("The user linked to this API key does not have permission for this action."); + apiDeny(apiPermissionText($resource_module[$resource], $required_level)); } // --- 3) Target client for writes: taken from the request, validated against the user --- diff --git a/api/v1/roles/read.php b/api/v1/roles/read.php new file mode 100644 index 000000000..afcfad14d --- /dev/null +++ b/api/v1/roles/read.php @@ -0,0 +1,74 @@ + Roles) and what each opens. A key whose + * user is an admin reads every role; any other key reads only its own user's + * role - so an integration can check what it may do. + * + * Parameters (GET): + * api_key required - Your API key + * role_id optional - One role, archived or not + * archived optional - 1 to include archived roles in the list + * limit optional - Max rows to return (default 50) + * offset optional - Offset for pagination (default 0) + * + * Each role: role_id, role_name, role_description, role_is_admin (1 or 0 - an + * admin role opens everything and its levels don't apply), role_levels (each + * module's level by its name: 0 none, 1 read, 2 modify - add, edit and + * archive - and 3 full, delete too), role_agents (how many active agents + * have it), role_created_at, role_updated_at and role_archived_at. + */ + +require_once '../validate_api_key.php'; + +require_once '../require_get_method.php'; + +$where = '1'; +if (!$session_is_admin) { + // Not an admin - only its own role + $where = 'role_id = ' . intval($session_user_role); +} elseif (isset($_GET['role_id'])) { + $where = 'role_id = ' . intval($_GET['role_id']); +} +if (!isset($_GET['role_id']) && $session_is_admin && intval($_GET['archived'] ?? 0) !== 1) { + $where .= ' AND role_archived_at IS NULL'; +} + +$sql = mysqli_query($mysqli, "SELECT role_id, role_name, role_description, role_is_admin, role_created_at, role_updated_at, role_archived_at, + (SELECT COUNT(*) FROM users WHERE user_role_id = role_id AND user_type = 1 AND user_archived_at IS NULL) AS role_agents + FROM user_roles WHERE role_type = 1 AND $where + ORDER BY role_is_admin DESC, role_name ASC LIMIT $limit OFFSET $offset"); + +if ($sql && mysqli_num_rows($sql) > 0) { + $roles = []; + while ($row = mysqli_fetch_assoc($sql)) { + $roles[intval($row['role_id'])] = $row; + } + $levels = roleLevels(array_keys($roles)); + $rows = []; + foreach ($roles as $role_id => $row) { + $role_levels = []; + foreach (roleModules() as $module_id => $module) { + $role_levels[$module['module_name']] = min(intval($levels[$role_id][$module_id] ?? 0), $module['max']); + } + $rows[] = [ + 'role_id' => $role_id, + 'role_name' => $row['role_name'], + 'role_description' => $row['role_description'], + 'role_is_admin' => intval($row['role_is_admin']), + 'role_levels' => $role_levels, + 'role_agents' => intval($row['role_agents']), + 'role_created_at' => $row['role_created_at'], + 'role_updated_at' => $row['role_updated_at'], + 'role_archived_at' => $row['role_archived_at'], + ]; + } + echo json_encode(['success' => 'True', 'count' => count($rows), 'data' => $rows]); + exit(); +} + +// Output +require_once "../read_output.php"; diff --git a/api/v1/technicians/read.php b/api/v1/technicians/read.php new file mode 100644 index 000000000..a5723213f --- /dev/null +++ b/api/v1/technicians/read.php @@ -0,0 +1,42 @@ + NOW() LIMIT 1"); + $sql = mysqli_query($mysqli, "SELECT api_key_id, api_key_decrypt_hash, api_key_name, api_key_user_id FROM api_keys WHERE api_key_secret = '$api_key' AND api_key_expire > NOW() LIMIT 1"); // Failed if (mysqli_num_rows($sql) !== 1) { @@ -96,6 +96,17 @@ if (isset($api_key)) { $api_key_decrypt_hash = $row['api_key_decrypt_hash']; // No sanitization $api_key_user_id = intval($row['api_key_user_id']); + // When it was last used, for Admin > API Keys - at most once a minute, not every call. In + // ITFlow's own timezone, as the pages show it: the API doesn't set the clocks to it. + $api_tz = mysqli_fetch_row(mysqli_query($mysqli, "SELECT config_timezone FROM settings WHERE company_id = 1"))[0] ?? ''; + try { + $api_now = (new DateTime('now', new DateTimeZone($api_tz ?: 'UTC')))->format('Y-m-d H:i:s'); + } catch (Exception $e) { + $api_now = date('Y-m-d H:i:s'); + } + mysqli_query($mysqli, "UPDATE api_keys SET api_key_last_used_at = '$api_now' WHERE api_key_id = " . intval($row['api_key_id']) . " + AND (api_key_last_used_at IS NULL OR api_key_last_used_at < '$api_now' - INTERVAL 1 MINUTE)"); + // Set limit & offset for queries if (isset($_GET['limit'])) { $limit = intval($_GET['limit']); diff --git a/db.sql b/db.sql index 2cccb9050..985659f12 100644 --- a/db.sql +++ b/db.sql @@ -119,6 +119,7 @@ CREATE TABLE `api_keys` ( `api_key_created_at` datetime NOT NULL DEFAULT current_timestamp(), `api_key_expire` date NOT NULL, `api_key_user_id` int(11) NOT NULL DEFAULT 0, + `api_key_last_used_at` datetime DEFAULT NULL, PRIMARY KEY (`api_key_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/functions.php b/functions.php index 9426bef13..d60eecf1c 100644 --- a/functions.php +++ b/functions.php @@ -21,6 +21,7 @@ require_once __DIR__ . '/functions/certificate.php'; require_once __DIR__ . '/functions/network.php'; require_once __DIR__ . '/functions/user.php'; require_once __DIR__ . '/functions/roles.php'; +require_once __DIR__ . '/functions/api_keys.php'; require_once __DIR__ . '/functions/asset_interface.php'; require_once __DIR__ . '/functions/rack.php'; require_once __DIR__ . '/functions/asset.php'; diff --git a/functions/api_keys.php b/functions/api_keys.php new file mode 100644 index 000000000..a5415fc8e --- /dev/null +++ b/functions/api_keys.php @@ -0,0 +1,174 @@ + 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. + */ + +/** The keys, by name - the row with its agent's name, picture, type, status, archived and role. */ +function apiKeyRows($search = '') { + global $mysqli; + $where = $search !== '' ? "WHERE api_key_name LIKE '%" . escapeSql($search) . "%' OR user_name LIKE '%" . escapeSql($search) . "%'" : ''; + $rows = []; + $sql = 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 + ORDER BY api_key_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[intval($row['api_key_id'])] = $row; + } + return $rows; +} + +/* One key as apiKeyRows() gives it, or null. */ +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.
+
+ +