Roles stage 3: roles and agents in the API and Admin > API Keys rebuilt; new api/v1/roles/read.php where an admin key reads every role with its level per module and agent count, archived on request, and any other key reads only its own user's role, and new api/v1/technicians/read.php listing agents with their role under Support at Read; API role refusals now say what the call needs and what the key's user has, naming the module, level and role, and technicians/time.php needs Read instead of Modify; Admin > API Keys shows each key's agent and role, what it can open and for which clients, whether it works (works until, expires in N days, expired, or won't work with the reason when its agent is disabled, archived, has no role or is missing) and when it was last used, with search by key or agent name; New and Edit windows show the picked agent's access as they are picked and offer only active agents, Create before ticking the copied box opens the Key tab, new keys default to a year and must end after today, and an expired key can be renamed and stay expired; handlers moved to functions/api_keys.php with validation, escaped messages and audit entries naming the agent and what an edit changed, fixing empty names on revoke, delete and bulk delete, a backslash shown for apostrophes in names, the bulk count of keys ticked rather than deleted, keys created already expired and expired keys that could not be renamed; database 3.1.7 adds api_keys.api_key_last_used_at, set at most once a minute in the configured timezone; changelog updated

This commit is contained in:
johnnyq
2026-10-17 21:10:48 -04:00
parent 5d9b48e83e
commit 2a5533ea2b
13 changed files with 611 additions and 278 deletions

View File

@@ -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 ---

74
api/v1/roles/read.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
/*
* API - Roles - Read
* GET /api/v1/roles/read.php
*
* The roles agents can have (Admin > 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";

View File

@@ -0,0 +1,42 @@
<?php
/*
* API - Technicians - Read
* GET /api/v1/technicians/read.php
*
* The agents - who tickets can be assigned to - with their role. Needs Read
* on Support. Sign in details (password, MFA, tokens) are never included.
*
* Parameters (GET):
* api_key required - Your API key
* technician_id optional - One agent, archived or not
* archived optional - 1 to include archived agents in the list
* limit optional - Max rows to return (default 50)
* offset optional - Offset for pagination (default 0)
*
* Each: technician_id, technician_name, technician_email, technician_status
* (1 active, 0 disabled), role_id, role_name, role_is_admin (1 or 0),
* technician_created_at and technician_archived_at.
*/
require_once '../validate_api_key.php';
require_once '../require_get_method.php';
$where = 'user_type = 1';
if (isset($_GET['technician_id'])) {
$where .= ' AND user_id = ' . intval($_GET['technician_id']);
} elseif (intval($_GET['archived'] ?? 0) !== 1) {
$where .= ' AND user_archived_at IS NULL';
}
$sql = mysqli_query($mysqli, "SELECT user_id AS technician_id, user_name AS technician_name, user_email AS technician_email,
user_status AS technician_status, user_role_id AS role_id, role_name, IFNULL(role_is_admin, 0) AS role_is_admin,
user_created_at AS technician_created_at, user_archived_at AS technician_archived_at
FROM users
LEFT JOIN user_roles ON role_id = user_role_id
WHERE $where
ORDER BY user_name ASC LIMIT $limit OFFSET $offset");
// Output
require_once "../read_output.php";

View File

@@ -70,7 +70,7 @@ if (isset($_POST['api_key'])) {
if (isset($api_key)) {
$api_key = escapeSql($api_key);
$sql = mysqli_query($mysqli, "SELECT 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");
$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']);