Add Bearer API: list/read tickets, post comments, change status
Lint / PHP (phpcs PSR-12) (push) Successful in 41s
Lint / JS (eslint) (push) Successful in 11s
Lint / PHP requirements (version + extensions) (push) Successful in 44s
Security / PHP Security (semgrep) (push) Successful in 2m47s
Lint / Deploy (push) Successful in 2s
Lint / Notify on failure (push) Has been skipped

Extends the Bearer-key API beyond create-only (all rate-limited, scope-
enforced, per-key-label attribution):
- GET /api/tickets_api.php: triage the queue (status/priority/host title
  match + pagination) or read one ticket + its comments. read scope.
- POST /api/ticket_comment_api.php: post a comment as the key (user_name =
  key name, linked to the key owner). read_write scope.
- POST /api/ticket_status_api.php: change/close status with workflow
  validation + requires_comment; posts the close reason in the same call,
  fires the Matrix status notification, invalidates stats. read_write scope.

Reuses TicketModel/CommentModel/WorkflowModel/NotificationHelper; a read
key cannot mutate. Reachability requires the reverse-proxy Authelia bypass
(handled separately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:40:52 -04:00
co-authored by Claude Opus 4.8
parent 5cf5aa9591
commit d46f8ffd77
3 changed files with 467 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* ticket_comment_api.php — Bearer-key endpoint to post a comment on a ticket.
*
* POST only. Requires 'read_write' scope.
*
* Identity = PER-KEY LABEL: the comment author (ticket_comments.user_name) is the
* API key's key_name and the linked user_id is the key's created_by.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "comment_text": "..." (required, non-empty),
* "markdown_enabled": bool (optional)
* }
* Response: {success:true, comment_id:...}
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
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';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_comment_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;
}
// 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;
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* ticket_status_api.php — Bearer-key endpoint to change a ticket's status.
*
* POST only. Requires 'read_write' scope.
*
* Body (JSON): {
* "ticket_id": "NNN" (required),
* "status": "..." (required target status),
* "comment": "..." (optional; REQUIRED when the transition
* requires_comment),
* "markdown_enabled": bool (optional, applies to the comment)
* }
* Response: {success:true, ticket_id, status}
*
* Mirrors api/update_ticket.php: workflow validation, requires_comment
* enforcement, updateTicket (updated_by/updated_at + closed_at handling), Matrix
* status-change notification, and StatsModel cache invalidation. When a comment
* is supplied it is posted first (per-key label) so "close with reason" is one call.
*/
header('Content-Type: application/json');
error_reporting(E_ALL);
ini_set('display_errors', 0);
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';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
try {
$conn = Database::getConnection();
} catch (Throwable $e) {
error_log('ticket_status_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;
}
// 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;
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* tickets_api.php — Bearer-key read endpoint (list/triage + read-one).
*
* GET only. Requires 'read' scope (a 'read_write' key also satisfies it).
* Acts as a trusted automation/server credential: reads return the full queue
* (no per-user visibility filtering).
*
* GET ?ticket_id=NNN -> {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;