diff --git a/README.md b/README.md index b322062..3f0ce79 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,22 @@ The following features are intentionally **not planned** for this system: - **Required Fields**: Mark fields as required for validation ### API Key Management -- **Admin UI**: Generate and manage API keys at `/admin/api-keys` +- **Admin UI**: Generate and manage API keys at `/admin/api-keys` (paginated) - **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header +- **Key Scopes**: `read` (GET only) or `read_write` (create/comment/close). A `read` key cannot mutate anything, including creating tickets. Existing keys default to `read_write`. - **Expiration**: Optional expiration dates for keys - **Revocation**: Revoke compromised keys instantly +### Bearer API (automation / triage) +All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exempt from Authelia at the reverse proxy — the API key is the only credential. Comments/closes made via the API are attributed to the **key's name** (linked to the key's owner). + +| Endpoint | Method | Scope | Purpose | +|----------|--------|-------|---------| +| `/create_ticket_api.php` | POST | read_write | Create a ticket (hwmonDaemon, external tools) | +| `/api/tickets_api.php` | GET | read | List/triage the queue (`?status=`, `?priority=`, `?host=` [title match], `?page=`, `?limit=`) **or** read one (`?ticket_id=NNN`) with its comments | +| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` | +| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call | + ### User Management & Authentication - **SSO Integration**: Authelia authentication with LLDAP backend - **Role-Based Access**: Admin and standard user roles @@ -250,6 +261,9 @@ Content-Type: application/json | Endpoint | Method | Description | |----------|--------|-------------| | `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) | +| `/api/tickets_api.php` | GET | Bearer: list/triage queue or read one ticket + comments | +| `/api/ticket_comment_api.php` | POST | Bearer: add a comment (read_write scope) | +| `/api/ticket_status_api.php` | POST | Bearer: change/close status, workflow-validated (read_write scope) | | `/api/update_ticket.php` | POST | Update ticket with workflow validation | | `/api/assign_ticket.php` | POST | Assign ticket to user | | `/api/add_comment.php` | POST | Add comment to ticket | diff --git a/api/generate_api_key.php b/api/generate_api_key.php index 77f6dec..855d9e3 100644 --- a/api/generate_api_key.php +++ b/api/generate_api_key.php @@ -59,12 +59,19 @@ try { $keyName = trim($input['key_name'] ?? ''); $expiresInDays = $input['expires_in_days'] ?? null; + $scope = $input['scope'] ?? 'read_write'; if (empty($keyName)) { http_response_code(400); throw new Exception("Key name is required"); } + // Validate scope — only the two known values are allowed + if (!in_array($scope, ['read', 'read_write'], true)) { + http_response_code(400); + throw new Exception("Invalid scope: must be 'read' or 'read_write'"); + } + if (strlen($keyName) > 100) { http_response_code(400); throw new Exception("Key name must be 100 characters or less"); @@ -86,7 +93,7 @@ try { // Generate API key $apiKeyModel = new ApiKeyModel($conn); - $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays); + $result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays, $scope); if (!$result['success']) { throw new Exception($result['error'] ?? "Failed to generate API key"); @@ -99,7 +106,7 @@ try { 'create', 'api_key', $result['key_id'], - ['key_name' => $keyName, 'expires_in_days' => $expiresInDays] + ['key_name' => $keyName, 'expires_in_days' => $expiresInDays, 'scope' => $scope] ); // Clear output buffer @@ -112,6 +119,7 @@ try { 'api_key' => $result['api_key'], 'key_prefix' => $result['key_prefix'], 'key_id' => $result['key_id'], + 'scope' => $result['scope'], 'expires_at' => $result['expires_at'] ]); } catch (Exception $e) { diff --git a/api/ticket_comment_api.php b/api/ticket_comment_api.php new file mode 100644 index 0000000..da9f303 --- /dev/null +++ b/api/ticket_comment_api.php @@ -0,0 +1,125 @@ +getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + exit; +} + +$apiKeyAuth = new ApiKeyAuth($conn); + +try { + $apiKeyAuth->authenticate(); +} catch (Exception $e) { + // ApiKeyAuth already sent the 401 response. + exit; +} + +// Posting a comment is a write — reject 'read' keys with 403 before any mutation. +$apiKeyAuth->requireScope('read_write'); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']); + exit; +} + +$context = $apiKeyAuth->getKeyContext(); +$keyName = $context['key_name'] ?? 'API'; +$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null; + +$rawInput = file_get_contents('php://input'); +$data = json_decode($rawInput, true); +if (!is_array($data)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid JSON body']); + exit; +} + +$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; +if ($ticketId === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ticket_id is required']); + exit; +} + +$commentText = isset($data['comment_text']) ? trim((string)$data['comment_text']) : ''; +if ($commentText === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'comment_text is required']); + exit; +} + +$markdownEnabled = !empty($data['markdown_enabled']); + +// Validate the ticket exists. +$ticketModel = new TicketModel($conn); +$ticket = $ticketModel->getTicketById($ticketId); +if (!$ticket) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; +} + +// Post the comment under the key's label / owner. +$commentModel = new CommentModel($conn); +$result = $commentModel->addComment($ticketId, [ + 'user_name' => $keyName, + 'comment_text' => $commentText, + 'markdown_enabled' => $markdownEnabled, +], $createdBy); + +if (empty($result['success'])) { + error_log('ticket_comment_api: addComment failed for ticket ' . $ticketId + . ': ' . ($result['error'] ?? 'unknown')); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to add comment']); + exit; +} + +$commentId = $result['comment_id'] ?? null; + +// Audit trail (action 'comment' / entity 'comment' are both whitelisted). +$auditLog = new AuditLogModel($conn); +$auditLog->log($createdBy, 'comment', 'comment', (string)$commentId, [ + 'ticket_id' => $ticketId, + 'key_name' => $keyName, + 'via_api' => true, +]); + +echo json_encode(['success' => true, 'comment_id' => $commentId]); +exit; diff --git a/api/ticket_status_api.php b/api/ticket_status_api.php new file mode 100644 index 0000000..144ce68 --- /dev/null +++ b/api/ticket_status_api.php @@ -0,0 +1,203 @@ +getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + exit; +} + +$apiKeyAuth = new ApiKeyAuth($conn); + +try { + $apiKeyAuth->authenticate(); +} catch (Exception $e) { + // ApiKeyAuth already sent the 401 response. + exit; +} + +// Changing status is a write — reject 'read' keys with 403 before any mutation. +$apiKeyAuth->requireScope('read_write'); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed. Use POST.']); + exit; +} + +$context = $apiKeyAuth->getKeyContext(); +$keyName = $context['key_name'] ?? 'API'; +$createdBy = ($context['created_by'] ?? null) !== null ? (int)$context['created_by'] : null; + +$rawInput = file_get_contents('php://input'); +$data = json_decode($rawInput, true); +if (!is_array($data)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid JSON body']); + exit; +} + +$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : ''; +if ($ticketId === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ticket_id is required']); + exit; +} + +$newStatus = isset($data['status']) ? trim((string)$data['status']) : ''; +if ($newStatus === '') { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'status is required']); + exit; +} + +$comment = isset($data['comment']) ? trim((string)$data['comment']) : ''; + +// Validate the ticket exists. +$ticketModel = new TicketModel($conn); +$ticket = $ticketModel->getTicketById($ticketId); +if (!$ticket) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; +} + +$currentStatus = (string)$ticket['status']; + +// Validate the transition (API key is never admin). +$workflowModel = new WorkflowModel($conn); +if (!$workflowModel->isTransitionAllowed($currentStatus, $newStatus, false)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'Status transition not allowed: ' . $currentStatus . ' -> ' . $newStatus, + ]); + exit; +} + +// Enforce requires_comment transitions server-side. +if ($workflowModel->transitionRequiresComment($currentStatus, $newStatus) && $comment === '') { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'A comment is required for this status change', + 'requires_comment' => true, + ]); + exit; +} + +// Post the comment first (per-key label) so a close-with-reason is one call. +if ($comment !== '') { + $commentModel = new CommentModel($conn); + $commentResult = $commentModel->addComment($ticketId, [ + 'user_name' => $keyName, + 'comment_text' => $comment, + 'markdown_enabled' => !empty($data['markdown_enabled']), + ], $createdBy); + if (empty($commentResult['success'])) { + error_log('ticket_status_api: addComment failed for ticket ' . $ticketId + . ': ' . ($commentResult['error'] ?? 'unknown')); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to add comment']); + exit; + } +} + +// Apply the status change. updateTicket sets updated_by/updated_at and handles +// closed_at (set on close, cleared on reopen) via its own SQL. +$updateData = [ + 'ticket_id' => $ticketId, + 'title' => $ticket['title'], + 'description' => $ticket['description'], + 'category' => $ticket['category'], + 'type' => $ticket['type'], + 'status' => $newStatus, + 'priority' => (int)$ticket['priority'], +]; + +$updateResult = $ticketModel->updateTicket($updateData, $createdBy); +if (empty($updateResult['success'])) { + error_log('ticket_status_api: updateTicket failed for ticket ' . $ticketId + . ': ' . ($updateResult['error'] ?? 'unknown')); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to update ticket status']); + exit; +} + +// Notify, audit, and refresh stats only when the status actually changed. +if ($currentStatus !== $newStatus) { + NotificationHelper::sendStatusChangeNotification( + $ticketId, + $currentStatus, + $newStatus, + (string)$ticket['title'], + $keyName + ); + NotificationHelper::notifyWatchers( + $conn, + $ticketId, + (string)$ticket['title'], + 'status_changed', + ['old_status' => $currentStatus, 'new_status' => $newStatus, 'changed_by' => $keyName], + $createdBy, + $ticket['visibility'] ?? 'public' + ); + + // Audit trail (action 'update' / entity 'ticket' are both whitelisted). + $auditLog = new AuditLogModel($conn); + $auditLog->log($createdBy, 'update', 'ticket', $ticketId, [ + 'status' => ['from' => $currentStatus, 'to' => $newStatus], + 'key_name' => $keyName, + 'via_api' => true, + ]); + + // Status change is a ticket-state change — refresh dashboard stats. + (new StatsModel($conn))->invalidateCache(); +} + +echo json_encode([ + 'success' => true, + 'ticket_id' => $ticketId, + 'status' => $newStatus, +]); +exit; diff --git a/api/tickets_api.php b/api/tickets_api.php new file mode 100644 index 0000000..5a04ee0 --- /dev/null +++ b/api/tickets_api.php @@ -0,0 +1,139 @@ + {success, ticket, comments} + * GET ?status=&priority=&host= -> {success, tickets, page, total, pages} + * &page=&limit= + */ + +header('Content-Type: application/json'); + +error_reporting(E_ALL); +ini_set('display_errors', 0); + +// Rate limiting (same pattern as the other Bearer API endpoints) +require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php'; +RateLimitMiddleware::apply('api'); + +require_once dirname(__DIR__) . '/config/config.php'; +require_once dirname(__DIR__) . '/helpers/Database.php'; +require_once dirname(__DIR__) . '/middleware/ApiKeyAuth.php'; +require_once dirname(__DIR__) . '/models/TicketModel.php'; +require_once dirname(__DIR__) . '/models/CommentModel.php'; + +try { + $conn = Database::getConnection(); +} catch (Throwable $e) { + error_log('tickets_api: DB connection failed: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Internal server error']); + exit; +} + +$apiKeyAuth = new ApiKeyAuth($conn); + +try { + $apiKeyAuth->authenticate(); +} catch (Exception $e) { + // ApiKeyAuth already sent the 401 response. + exit; +} + +// Reads only need the 'read' scope. +$apiKeyAuth->requireScope('read'); + +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed. Use GET.']); + exit; +} + +$ticketModel = new TicketModel($conn); + +// ── READ ONE ────────────────────────────────────────────────────────────── +if (isset($_GET['ticket_id']) && trim((string)$_GET['ticket_id']) !== '') { + $ticketId = trim((string)$_GET['ticket_id']); + + $ticket = $ticketModel->getTicketById($ticketId); + if (!$ticket) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; + } + + // Flat list of comments (newest first) — same fetch the ticket view uses. + $commentModel = new CommentModel($conn); + $comments = $commentModel->getCommentsByTicketId($ticketId, false); + + echo json_encode([ + 'success' => true, + 'ticket' => $ticket, + 'comments' => $comments, + ]); + exit; +} + +// ── LIST / TRIAGE ─────────────────────────────────────────────────────────── +$status = (isset($_GET['status']) && trim((string)$_GET['status']) !== '') + ? trim((string)$_GET['status']) + : 'Open'; + +$page = isset($_GET['page']) ? (int)$_GET['page'] : 1; +if ($page < 1) { + $page = 1; +} + +$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 25; +if ($limit < 1) { + $limit = 25; +} +if ($limit > 100) { + $limit = 100; // cap +} + +$filters = []; +if (isset($_GET['priority']) && trim((string)$_GET['priority']) !== '') { + $priority = (int)$_GET['priority']; + if ($priority >= 1 && $priority <= 5) { + // Exact-priority match via the min/max range filter. + $filters['priority_min'] = $priority; + $filters['priority_max'] = $priority; + } +} + +// hwmon puts the host in the title (e.g. "[hostname] ..."), so a host filter is a +// title substring match — served by getAllTickets's `search` param (title search). +$search = null; +if (isset($_GET['host']) && trim((string)$_GET['host']) !== '') { + $search = trim((string)$_GET['host']); +} + +// user = null => getAllTickets skips visibility filtering and returns the full +// queue (this is a trusted server credential, not an end user). +$result = $ticketModel->getAllTickets( + $page, + $limit, + $status, + 'ticket_id', + 'desc', + null, + null, + $search, + $filters, + null +); + +echo json_encode([ + 'success' => true, + 'tickets' => $result['tickets'], + 'page' => $result['current_page'], + 'total' => $result['total'], + 'pages' => $result['pages'], +]); +exit; diff --git a/create_ticket_api.php b/create_ticket_api.php index 3d61579..62b6fcc 100644 --- a/create_ticket_api.php +++ b/create_ticket_api.php @@ -72,6 +72,9 @@ try { exit; } +// Ticket creation is a write — a read-only key must be rejected with 403. +$apiKeyAuth->requireScope('read_write'); + $userId = $systemUser['user_id']; // Parse input regardless of content-type header diff --git a/index.php b/index.php index f9732ed..119f68f 100644 --- a/index.php +++ b/index.php @@ -331,7 +331,15 @@ switch (true) { requireAdmin($currentUser); require_once 'models/ApiKeyModel.php'; $apiKeyModel = new ApiKeyModel($conn); - $apiKeys = $apiKeyModel->getAllKeys(); + + // Validate the requested page to a positive int (default 1) + $apiKeysPage = isset($_GET['page']) ? (int)$_GET['page'] : 1; + if ($apiKeysPage < 1) { + $apiKeysPage = 1; + } + $apiKeysPerPage = 20; + + $apiKeys = $apiKeyModel->getAllKeys($apiKeysPage, $apiKeysPerPage); include 'views/admin/ApiKeysView.php'; break; diff --git a/middleware/ApiKeyAuth.php b/middleware/ApiKeyAuth.php index 6b1517a..cf37670 100644 --- a/middleware/ApiKeyAuth.php +++ b/middleware/ApiKeyAuth.php @@ -13,6 +13,14 @@ class ApiKeyAuth private $userModel; private $conn; + /** + * Context of the API key validated by the most recent authenticate()/ + * verifyOptional() call, or null if none succeeded. + * + * @var array|null + */ + private $keyContext = null; + public function __construct($conn) { $this->conn = $conn; @@ -20,6 +28,57 @@ class ApiKeyAuth $this->userModel = new UserModel($conn); } + /** + * Store the validated key's context for later scope/attribution checks. + * + * @param array $keyData Row returned by ApiKeyModel::validateKey() + */ + private function setKeyContext(array $keyData) + { + $this->keyContext = [ + 'scope' => $keyData['scope'] ?? 'read_write', + 'key_name' => $keyData['key_name'] ?? null, + 'created_by' => $keyData['created_by'] ?? null, + 'api_key_id' => $keyData['api_key_id'] ?? null, + ]; + } + + /** + * Get the context of the authenticated API key. + * + * @return array|null ['scope', 'key_name', 'created_by', 'api_key_id'] or null + */ + public function getKeyContext(): ?array + { + return $this->keyContext; + } + + /** + * Enforce that the authenticated key satisfies the required scope. + * + * A 'read' key satisfies only 'read'; a 'read_write' key satisfies both + * 'read' and 'read_write'. On failure a 403 JSON error is sent and the + * script exits. + * + * @param string $needed Required scope ('read' or 'read_write') + */ + public function requireScope(string $needed): void + { + $current = $this->keyContext['scope'] ?? null; + + // 'read_write' can do anything; 'read' can only satisfy a 'read' need. + $ok = ($current === 'read_write') + || ($current === 'read' && $needed === 'read'); + + if (!$ok) { + $this->sendForbidden( + 'API key scope "' . ($current ?? 'none') . '" is insufficient; "' + . $needed . '" is required' + ); + exit; + } + } + /** * Authenticate using API key from Authorization header * @@ -52,6 +111,9 @@ class ApiKeyAuth exit; } + // Record key context (scope / attribution) for callers to inspect. + $this->setKeyContext($keyData); + // Get system user (or the user who created the key) $user = $this->userModel->getSystemUser(); @@ -113,6 +175,22 @@ class ApiKeyAuth ]); } + /** + * Send 403 Forbidden response (e.g. insufficient scope) + * + * @param string $message Error message + */ + private function sendForbidden($message) + { + header('HTTP/1.1 403 Forbidden'); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'error' => 'Forbidden', + 'message' => $message + ]); + } + /** * Verify API key without throwing errors (for optional auth) * @@ -137,6 +215,9 @@ class ApiKeyAuth return null; } + // Record key context (scope / attribution) for callers to inspect. + $this->setKeyContext($keyData); + $user = $this->userModel->getSystemUser(); if ($user) { diff --git a/migrations/000_baseline.sql b/migrations/000_baseline.sql index dab0008..42af57c 100644 --- a/migrations/000_baseline.sql +++ b/migrations/000_baseline.sql @@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `api_keys` ( `key_hash` varchar(255) NOT NULL, `key_prefix` varchar(20) NOT NULL, `is_active` tinyint(1) DEFAULT 1, + `scope` enum('read','read_write') NOT NULL DEFAULT 'read_write', `created_by` int(11) DEFAULT NULL, `last_used` timestamp NULL DEFAULT NULL, `expires_at` timestamp NULL DEFAULT NULL, diff --git a/models/ApiKeyModel.php b/models/ApiKeyModel.php index bf3cbed..9213030 100644 --- a/models/ApiKeyModel.php +++ b/models/ApiKeyModel.php @@ -18,10 +18,19 @@ class ApiKeyModel * @param string $keyName Descriptive name for the key * @param int $createdBy User ID who created the key * @param int|null $expiresInDays Number of days until expiration (null for no expiration) - * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error' + * @param string $scope Access scope: 'read' or 'read_write' (default 'read_write') + * @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'scope', 'error' */ - public function createKey($keyName, $createdBy, $expiresInDays = null) + public function createKey($keyName, $createdBy, $expiresInDays = null, $scope = 'read_write') { + // Validate the requested scope — only the two known values are allowed + if (!in_array($scope, ['read', 'read_write'], true)) { + return [ + 'success' => false, + 'error' => "Invalid scope: must be 'read' or 'read_write'" + ]; + } + // Generate random API key (32 bytes = 64 hex characters) $apiKey = bin2hex(random_bytes(32)); @@ -39,9 +48,10 @@ class ApiKeyModel // Insert API key into database $stmt = $this->conn->prepare( - "INSERT INTO api_keys (key_name, key_hash, key_prefix, created_by, expires_at) VALUES (?, ?, ?, ?, ?)" + "INSERT INTO api_keys (key_name, key_hash, key_prefix, scope, created_by, expires_at) " + . "VALUES (?, ?, ?, ?, ?, ?)" ); - $stmt->bind_param("sssis", $keyName, $keyHash, $keyPrefix, $createdBy, $expiresAt); + $stmt->bind_param("ssssis", $keyName, $keyHash, $keyPrefix, $scope, $createdBy, $expiresAt); if ($stmt->execute()) { $keyId = $this->conn->insert_id; @@ -52,6 +62,7 @@ class ApiKeyModel 'api_key' => $apiKey, // Return plaintext key ONCE 'key_prefix' => $keyPrefix, 'key_id' => $keyId, + 'scope' => $scope, 'expires_at' => $expiresAt ]; } else { @@ -96,6 +107,13 @@ class ApiKeyModel $keyData = $result->fetch_assoc(); $stmt->close(); + // Ensure a scope is always present. On an un-migrated database the column + // does not exist yet (or is null), in which case we treat the key as + // full-access so existing integrations keep working. + if (!isset($keyData['scope']) || $keyData['scope'] === null || $keyData['scope'] === '') { + $keyData['scope'] = 'read_write'; + } + // Check expiration if ($keyData['expires_at'] !== null) { $expiresAt = strtotime($keyData['expires_at']); @@ -156,18 +174,41 @@ class ApiKeyModel } /** - * Get all API keys (for admin panel) + * Get a page of API keys (for admin panel) * - * @return array Array of API key records (without hashes) + * Active keys are listed first, then newest first within each group. + * + * @param int $page 1-based page number + * @param int $perPage Rows per page + * @return array ['keys' => array, 'total' => int, 'page' => int, 'perPage' => int] */ - public function getAllKeys() + public function getAllKeys($page = 1, $perPage = 20) { + // Normalise pagination inputs + $page = max(1, (int)$page); + $perPage = (int)$perPage; + if ($perPage < 1) { + $perPage = 20; + } + $offset = ($page - 1) * $perPage; + + // Total count for pagination controls + $total = 0; + $countResult = $this->conn->query("SELECT COUNT(*) AS total FROM api_keys"); + if ($countResult) { + $countRow = $countResult->fetch_assoc(); + $total = (int)($countRow['total'] ?? 0); + $countResult->free(); + } + $stmt = $this->conn->prepare( "SELECT ak.*, u.username, u.display_name FROM api_keys ak LEFT JOIN users u ON ak.created_by = u.user_id - ORDER BY ak.created_at DESC" + ORDER BY ak.is_active DESC, ak.created_at DESC + LIMIT ? OFFSET ?" ); + $stmt->bind_param("ii", $perPage, $offset); $stmt->execute(); $result = $stmt->get_result(); @@ -179,7 +220,13 @@ class ApiKeyModel } $stmt->close(); - return $keys; + + return [ + 'keys' => $keys, + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage + ]; } /** diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php index 633da9a..24b8e6e 100644 --- a/views/admin/ApiKeysView.php +++ b/views/admin/ApiKeysView.php @@ -38,8 +38,18 @@ include __DIR__ . '/../../views/layout_header.php'; +
+ Scope: read = GET only; read_write = create/comment/close. +
= htmlspecialchars($key['key_prefix']) ?>…Authorization: Bearer YOUR_API_KEY
-
- Example — create a ticket via cURL:
+
+
+ Scopes: a read key may only use the GET endpoints;
+ a read_write key may also create tickets, post comments, and change status.
+ All endpoints are Bearer-authenticated and rate-limited. Comments and status changes made via
+ the API are attributed to the key's name.
Create a ticket (read_write):
curl -X POST https://your-instance/create_ticket_api.php \
+ curl -X POST = $apiBase ?>/create_ticket_api.php \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"My ticket","category":"General","type":"Issue","priority":3}'
API keys provide programmatic access to create and manage tickets. Keep keys secure and rotate them regularly.
+ +List / triage the queue (read). Filters: status, priority (1-5), host (title match), page, limit:
curl "= $apiBase ?>/api/tickets_api.php?status=Open&priority=2&limit=25" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+ Read one ticket + its comments (read):
+curl "= $apiBase ?>/api/tickets_api.php?ticket_id=123456789" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+ Post a comment (read_write). markdown_enabled is optional:
curl -X POST = $apiBase ?>/api/ticket_comment_api.php \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"ticket_id":"123456789","comment_text":"Investigating.","markdown_enabled":true}'
+ Change / close status (read_write, workflow-validated). comment is required for transitions that require one (e.g. closing) and is posted as the reason:
curl -X POST = $apiBase ?>/api/ticket_status_api.php \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"ticket_id":"123456789","status":"Closed","comment":"Resolved: disk replaced."}'
+ Keep keys secure and rotate them regularly. Scope automation keys to read unless they need to write.