Compare commits

..
Author SHA1 Message Date
jaredandClaude Opus 5.5 9bbe4ef9a0 Merge development into main: OAuth-protected remote MCP server (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 19s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m34s
Lint / Deploy (push) Successful in 3s
Adds /mcp, a remote MCP server (official MCP PHP SDK, pinned 0.8.1)
protected by Authelia-issued OAuth access tokens, so Claude Code can work
with Tinker Tickets as the signed-in user: search_tickets, get_ticket,
create_ticket, add_comment, update_status, assign_ticket. Tools run the
same code paths as the web UI (ApiTicketController moved to its own
file; CommentService, AssignmentService and TicketCreationService
extracted from their web endpoints). Composer is introduced for the MCP
endpoint only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:21:30 -04:00
jaredandClaude Opus 5.5 17be55bf8f Add MCP write tools: create_ticket, add_comment, update_status, assign_ticket (#111, phase 5)
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m5s
Lint / Deploy (push) Successful in 2s
Each tool is a thin adapter over the same code path the web UI uses
(TicketCreationService, CommentService, ApiTicketController,
AssignmentService), run as the signed-in user, so permissions, Workflow
Designer rules, audit entries, notifications and stats-cache
invalidation are identical to doing the same thing in the browser.

- Registered in ToolCatalog and listed in its WRITE_TOOLS, so
  ToolScopeMiddleware requires tickets:write for them.
- Annotated readOnlyHint=false / destructiveHint=false (nothing deletes).
- Input the web form constrains with dropdowns (priority 1-5, visibility,
  status) is validated in the tools. Assignees are "me", a username or
  "unassigned".
- A ticket the user can't see reads as "not found" (never "access
  denied"), consistent with get_ticket.
- update_status turns requires_comment into an actionable error and
  invalidates the stats cache like api/update_ticket.php does.

Verified locally through the real pipeline (only JWT validation stubbed)
against MariaDB with seeded workflow transitions: 30/30 checks, including
a read-only token getting 403 insufficient_scope on create_ticket with
nothing written; create/comment/status/assign attributed and
audit-logged as the user; @mentions; internal visibility needing groups
and staying hidden from non-members; an invisible confidential ticket
not found for comment/status; requires_comment enforced, and closing
with a reason persisted in one transaction; transitions outside the
workflow refused; the admin/creator/assignee rule for assigning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:41 -04:00
jaredandClaude Opus 5.5 f206bb5889 Extract ticket creation from TicketController into TicketCreationService (#111)
Required-field and required-custom-field validation, createTicket
(which enforces visibility rules), the audit log, stats-cache
invalidation, custom field values, the optional 'duplicates' link and
the new-ticket notification move into services/TicketCreationService.php
with the same order and error messages, so the MCP create_ticket tool
runs one code path with the web form. TicketController::create keeps
CSRF, the redirect, and re-rendering the form on error (the view never
read the removed locals). The service also accepts visibility_groups as
a string for API callers; the controller still passes only the form's
array, so web behaviour is unchanged.

Verified the real web form over HTTP (logged in via Remote-User, real
CSRF token): a valid submit redirects to the new ticket, created and
audit-logged as the user; a blank description re-renders the form with
'Description is required' and creates nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 9e462f7f00 Extract ticket assignment from assign_ticket.php into AssignmentService (#111)
The access check, the admin/creator/current-assignee permission rule,
unassign/assign, the audit log, the optional Matrix assignment
notification and the stats-cache invalidation move into
services/AssignmentService.php for reuse by the MCP assign_ticket tool.
Error messages and status codes are unchanged.

One deliberate difference: assign_ticket.php's early error responses
(400/403/404) used a bare echo and so omitted the CSRF token that
bootstrap had just rotated. Every response now goes through
apiRespond(), which includes it. The front end already resyncs its
token from any response body, so this is compatible, and a failed
assign can no longer leave the page holding a stale token.

Verified over real HTTP: the assignee can reassign (200); a user who can
see the ticket but isn't admin/creator/assignee gets 403 'Permission
denied'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 d5832fb58a Extract comment creation from add_comment.php into CommentService (#111)
Validation, the ticket access check, reply-parent validation, @mention
extraction (audit-logged, notified only to mentioned users who can see
the ticket), and comment/watcher notifications move into
services/CommentService.php, so the MCP add_comment tool runs one code
path with the web UI. add_comment.php keeps session, CSRF, JSON parsing
and response codes. Error messages and status codes are unchanged; the
extracted body diffs against the original only where each 'emit error
and exit' became a 'return [..., http_status]'.

Verified the web endpoint over real HTTP: a comment is trimmed, saved
with its @mention and the rotated CSRF token returned; a confidential
ticket the user can't see still gets 403 'Access denied'; empty text
still gets 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 6ce3380d6a Move ApiTicketController out of update_ticket.php into its own file (#111)
The partial-update controller (status transitions incl. requires_comment
with the comment in the same transaction, field edits, visibility,
audit delta, status-change notifications) was defined inline inside
api/update_ticket.php, so nothing else could reuse it. Moved it verbatim
to controllers/ApiTicketController.php so the MCP update_status tool can
run the exact same code path as the web UI; update_ticket.php now just
require_once's it. The class body is byte-identical to the original
(diffed against HEAD, modulo the 4-space dedent).

Verified the web endpoint over real HTTP with a real session + CSRF:
Open -> In Progress succeeds, In Progress -> Closed without a comment is
refused with requires_comment (400), and with a comment closes the
ticket and persists the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:13:31 -04:00
jaredandClaude Opus 5.5 65deedc295 Add MCP identity mapping and read tools (#111, phase 4)
Lint / PHP (phpcs PSR-12) (push) Successful in 32s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m4s
Lint / Deploy (push) Successful in 2s
MCP requests now run as the signed-in Tinker Tickets user, under exactly
the same rules as the web login, and expose the first two tools:
search_tickets and get_ticket.

Identity:
- IdentityMiddleware maps the validated token to a user with the same
  checks as AuthMiddleware: the admin/employee group rule, now extracted
  into helpers/AccessPolicy.php so both entry points share one copy, then
  UserModel::syncUserFromAuthelia(), which creates/updates the row and
  derives is_admin from groups.
- Claims are read from the validated token's server-side PSR-7 request
  attributes, not from JSON-RPC _meta. The SDK's OAuthRequestMetaMiddleware
  is deliberately not used: it array_merges into client-writable _meta,
  so only the keys the validator happens to set are overwritten and a
  client could inject others.

Scopes (ToolScopeMiddleware), enforced before dispatch:
- lifecycle messages need only a valid token; write tools (listed in
  ToolCatalog, the single registry) need tickets:write; everything else
  needs tickets:read, which tickets:write implies.
- Denials are the spec's step-up challenge: 403 +
  WWW-Authenticate: Bearer error="insufficient_scope", scope=...,
  resource_metadata=...

Tools (read-only, annotated readOnlyHint):
- search_tickets: text/status/priority/category/assignee ("me",
  "unassigned", or a username), paginated, via TicketModel::getAllTickets
  with the user's visibility filter. Defaults to every non-Closed status.
- get_ticket: details + comments, gated by canUserAccessTicket. A missing
  ticket and a non-visible one return the same "not found".

Verified locally against a real MariaDB fixture (public, confidential,
internal+group, and closed tickets across two users), driving the real
pipeline (ToolCatalog, both middlewares, SDK transport) with only JWT
validation stubbed: 20/20 checks pass, including visibility parity per
user, confidential tickets hidden from non-owners, the group check
rejecting a user without admin/employee, a missing preferred_username
rejected, 403 insufficient_scope for a token without tickets:*, and
write implying read. Also exercised the stateless 2026-07-28 era (no
session, _meta + MCP-Protocol-Version/Mcp-Method/Mcp-Name headers), which
returns the same visibility-filtered results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:58:30 -04:00
jaredandClaude Opus 5.5 13660b4398 Fix duplicated Host header rejecting all MCP requests under PHP-FPM (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 12s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m8s
Lint / Deploy (push) Successful in 2s
nyholm's ServerRequestCreator adds Host both from the URI it builds and
from the request headers, so under PHP-FPM getHeaderLine('Host') returns
"beta.t.lotusguild.org, beta.t.lotusguild.org". The SDK's DNS-rebinding
check compares that joined string against the allowlist and refused every
request with 403 "Invalid Host header", even for the correct hostname.
It only passed locally because PHP's built-in server exposes headers
differently.

Collapse Host to the single value the client sent before the middleware
runs. The rebinding check still sees the client's real Host, so foreign
hosts and direct-by-IP access stay refused.

Verified on the beta host by running the patched entrypoint against
beta's real config and vendor/: the correct host gets the Protected
Resource Metadata, and a foreign host still gets 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:46:52 -04:00
jaredandClaude Opus 5.5 5631731a28 Add OAuth-protected MCP endpoint scaffolding (#111, phase 2)
Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m17s
Lint / Deploy (push) Successful in 7s
First step of the remote MCP server: mcp/server.php serves /mcp over
Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly
0.8.1 since it breaks BC in nearly every minor release), with Authelia as
the OAuth authorization server. No tools yet: this phase only stands up
authentication, RFC 9728 Protected Resource Metadata, and routing.

- Composer is introduced for the MCP endpoint ONLY: nothing else loads
  vendor/autoload.php, so a failed composer install at deploy time can
  only take /mcp down. vendor/ is gitignored and excluded from phpcs;
  composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4.
- Tokens are validated against Authelia's JWKS (cached) for signature,
  issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod
  and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts
  scopes in an array claim of that name, not the standard 'scope'.
- The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the
  SDK sees it: TLS ends at the proxy, so PHP sees http and a
  client-controlled Host, and the SDK builds the 401 challenge's
  resource_metadata URL from that. preserveHost keeps the real Host
  header for the DNS-rebinding check, which only allows the canonical
  hostname (so direct-by-IP access is refused too).
- Identity will come only from the token; this entrypoint never reads
  Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth
  at the proxy and those headers are client-controlled there.

Verified locally with PHP's built-in server: unauthenticated POST gets
401 + WWW-Authenticate with the https resource_metadata URL and scopes;
metadata served at both /.well-known/oauth-protected-resource/mcp and the
root form; malformed token -> 401 invalid_token; foreign Host and
direct-IP Host -> 403; a forged Remote-User header without a token is
still 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:40:11 -04:00
22 changed files with 4029 additions and 597 deletions
+3 -1
View File
@@ -6,4 +6,6 @@ settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
!uploads/.htaccess
!uploads/.htaccess
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
vendor/
+1
View File
@@ -6,6 +6,7 @@
<exclude-pattern>*/uploads/*</exclude-pattern>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/.gitea/*</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
+11 -144
View File
@@ -80,155 +80,22 @@ try {
exit;
}
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
http_response_code(400);
// Validation, access check, mentions, audit log and notifications live in
// CommentService so the MCP add_comment tool runs the same code path.
require_once dirname(__DIR__) . '/services/CommentService.php';
$result = CommentService::addComment($conn, $currentUser, $data);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
echo json_encode($result);
exit;
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
exit;
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated session user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(403);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Access denied']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
exit;
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
if (isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
if ($result['success'] && isset($newCsrfToken)) {
$result['csrf_token'] = $newCsrfToken;
}
// Discard any unexpected output
+9 -79
View File
@@ -1,11 +1,7 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
@@ -15,79 +11,13 @@ if (!is_array($data)) {
exit;
}
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
// Validation, permission check, audit log, notification and stats-cache
// invalidation live in AssignmentService so the MCP assign_ticket tool runs
// the same code path.
$result = AssignmentService::assign($conn, $currentUser, $data);
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
exit;
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
http_response_code(500);
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($conn))->invalidateCache();
apiRespond(['success' => true]);
if (!empty($result['http_status'])) {
http_response_code($result['http_status']);
unset($result['http_status']);
}
apiRespond($result);
+1 -256
View File
@@ -62,262 +62,7 @@ try {
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
// Updated controller class that handles partial updates
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
// Use centralized database connection
$conn = Database::getConnection();
+33
View File
@@ -0,0 +1,33 @@
{
"name": "lotusguild/tinker-tickets",
"description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.2",
"ext-openssl": "*",
"firebase/php-jwt": "^7.0",
"laminas/laminas-httphandlerrunner": "^2.12",
"mcp/sdk": "0.8.1",
"nyholm/psr7": "^1.8",
"nyholm/psr7-server": "^1.1",
"psr/simple-cache": "^3.0",
"symfony/cache": "^7.3",
"symfony/http-client": "^7.3"
},
"autoload": {
"psr-4": {
"TinkerTickets\\Mcp\\": "mcp/src/"
}
},
"config": {
"platform": {
"php": "8.2.0"
},
"sort-packages": true,
"optimize-autoloader": true,
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2421
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -102,6 +102,14 @@ $GLOBALS['config'] = [
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
// MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
// canonical URL: access tokens must carry it as their audience, so a
// beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
// Authelia issuer that signs those tokens.
'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
// Session settings
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* Partial ticket updates for the web UI (api/update_ticket.php) and the MCP
* update_status tool: workflow-validated status transitions (including
* requires_comment, with the comment persisted in the same transaction),
* priority/field edits, visibility changes (admin or creator only), audit
* log delta, and status-change notifications. Returns result arrays; callers
* own HTTP concerns (sessions, CSRF, response codes) and must invalidate the
* stats cache on success.
*
* Moved verbatim out of api/update_ticket.php so the MCP endpoint runs the
* exact same code path as the web UI (tinker_tickets#111).
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class ApiTicketController
{
private $conn;
private $ticketModel;
private $commentModel;
private $auditLog;
private $workflowModel;
private $userId;
private $isAdmin;
private $currentUser;
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
{
$this->conn = $conn;
$this->ticketModel = new TicketModel($conn);
$this->commentModel = new CommentModel($conn);
$this->auditLog = new AuditLogModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->userId = $userId;
$this->isAdmin = $isAdmin;
$this->currentUser = $currentUser;
}
public function update($id, $data)
{
// First, get the current ticket data to fill in missing fields
$currentTicket = $this->ticketModel->getTicketById($id);
if (!$currentTicket) {
return [
'success' => false,
'error' => 'Ticket not found'
];
}
// Visibility check: return 404 for tickets the user cannot access
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
return [
'success' => false,
'error' => 'Ticket not found',
'http_status' => 404
];
}
// Any authenticated team member can update tickets.
// Admin-only operations (delete, bulk actions) are enforced separately.
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,
'title' => $data['title'] ?? $currentTicket['title'],
'description' => $data['description'] ?? $currentTicket['description'],
'category' => $data['category'] ?? $currentTicket['category'],
'type' => $data['type'] ?? $currentTicket['type'],
'status' => $data['status'] ?? $currentTicket['status'],
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
];
// Validate required fields
if (empty($updateData['title'])) {
return [
'success' => false,
'error' => 'Title cannot be empty',
'http_status' => 400
];
}
// Validate priority range
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
return [
'success' => false,
'error' => 'Priority must be between 1 and 5',
'http_status' => 400
];
}
// Validate visibility BEFORE any DB write so a bad payload can't leave the
// ticket half-updated (core fields committed but request reported as failed).
$visibilityGroups = null;
if (isset($data['visibility'])) {
$visibilityGroups = $data['visibility_groups'] ?? null;
// Convert array to comma-separated string if needed
if (is_array($visibilityGroups)) {
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
}
// Authorization: only an admin or the ticket's creator may change
// visibility. Enforce only when the requested visibility actually
// differs so ordinary edits that re-send the same value aren't blocked.
$currentVisibility = $currentTicket['visibility'] ?? 'public';
$currentGroups = $currentTicket['visibility_groups'] ?? null;
$groupsProvided = array_key_exists('visibility_groups', $data);
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
if ($visibilityChanged) {
$isCreator = $this->userId !== null
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
if (!$this->isAdmin && !$isCreator) {
return [
'success' => false,
'error' => 'You do not have permission to change ticket visibility',
'http_status' => 403
];
}
}
// Internal visibility requires at least one group
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
return [
'success' => false,
'error' => 'Internal visibility requires at least one group to be specified',
'http_status' => 400
];
}
}
// Validate status transition using workflow model
if ($currentTicket['status'] !== $updateData['status']) {
$allowed = $this->workflowModel->isTransitionAllowed(
$currentTicket['status'],
$updateData['status'],
$this->isAdmin
);
if (!$allowed) {
return [
'success' => false,
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
];
}
// Enforce requires_comment transitions server-side.
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
if ($statusChangeComment === '') {
return [
'success' => false,
'error' => 'A comment is required for this status change',
'requires_comment' => true,
'http_status' => 400
];
}
}
}
// A comment accompanying a status change (required or optional) is
// persisted in the SAME transaction as the status update below, so
// a failure partway through can't leave an orphaned "reason"
// comment attached with no matching status change — the two
// previously ran as separate, non-transactional HTTP calls from
// the client (add_comment.php then update_ticket.php).
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
$result = null;
$this->conn->begin_transaction();
try {
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
$commentResult = $this->commentModel->addComment($id, [
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
'comment_text' => $statusChangeComment,
'markdown_enabled' => !empty($data['markdown_enabled']),
], $this->userId);
if (empty($commentResult['success'])) {
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
}
}
// Update ticket with user tracking and optional optimistic locking
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
if (!$result['success']) {
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
}
// Handle visibility update if provided (already validated above)
if (isset($data['visibility'])) {
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
if (!$visResult) {
throw new Exception('Failed to update ticket visibility');
}
}
$this->conn->commit();
} catch (Exception $e) {
$this->conn->rollback();
$response = ['success' => false, 'error' => $e->getMessage()];
if (is_array($result) && !empty($result['conflict'])) {
$response['conflict'] = true;
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
}
return $response;
}
if (isset($data['visibility']) && $this->userId) {
$this->auditLog->log(
$this->userId,
'update',
'ticket',
(string)$id,
[
'field' => 'visibility',
'from' => $currentTicket['visibility'] ?? 'public',
'to' => $data['visibility'],
'groups' => $visibilityGroups
]
);
}
// Log ticket update to audit log — only the changed fields (delta)
if ($this->userId) {
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
$delta = [];
foreach ($trackFields as $field) {
$oldVal = (string)($currentTicket[$field] ?? '');
$newVal = (string)($updateData[$field] ?? '');
if ($oldVal !== $newVal) {
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
}
}
if (!empty($delta)) {
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
}
}
// Notify on status change (global notify list + watchers)
if ($currentTicket['status'] !== $updateData['status']) {
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
NotificationHelper::sendStatusChangeNotification(
$id,
$currentTicket['status'],
$updateData['status'],
$updateData['title'],
$changedBy,
$currentTicket['visibility'] ?? 'public'
);
NotificationHelper::notifyWatchers(
$this->conn,
$id,
$updateData['title'],
'status_changed',
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
(int)$this->userId,
$currentTicket['visibility'] ?? 'public'
);
}
return [
'success' => true,
'status' => $updateData['status'],
'priority' => $updateData['priority'],
'updated_at' => date('Y-m-d H:i:s'),
'message' => 'Ticket updated successfully',
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
];
}
}
+14 -102
View File
@@ -101,115 +101,27 @@ class TicketController
return;
}
// Handle visibility groups (comes as array from checkboxes)
$visibilityGroups = null;
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $_POST['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($_POST['title'] ?? ''),
// Validation (incl. required custom fields), creation, audit log,
// stats cache, custom field values, duplicate link and notification
// live in TicketCreationService so the MCP create_ticket tool runs
// the same code path.
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
$result = TicketCreationService::create($this->conn, $currentUser ?? [], [
'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
'status' => $status,
'status' => $_POST['status'] ?? 'Open',
'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
];
// Validate input (server-side; form is novalidate)
if ($ticketData['title'] === '') {
$error = "Title is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if (trim($ticketData['description']) === '') {
$error = "Description is required";
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups']))
? $_POST['visibility_groups'] : null,
'assigned_to' => $_POST['assigned_to'] ?? null,
'custom_fields' => $_POST['custom_fields'] ?? [],
'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '',
]);
if ($result['success']) {
// Log ticket creation to audit log
if (isset($GLOBALS['auditLog']) && $userId) {
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $this->conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
+31
View File
@@ -0,0 +1,31 @@
<?php
/**
* Who may use Tinker Tickets at all. Shared by the web login
* (AuthMiddleware, from Authelia's Remote-Groups header) and the MCP endpoint
* (from the OAuth access token's groups claim), so both entry points enforce
* one rule instead of two copies that can drift apart.
*/
class AccessPolicy
{
/** Membership in any of these grants access. */
private const REQUIRED_GROUPS = ['admin', 'employee'];
/**
* @param string $groups Comma-separated group names (Remote-Groups format)
*/
public static function hasAppAccess(string $groups): bool
{
if ($groups === '') {
return false;
}
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
fn($g) => preg_match('/^[a-z0-9_\-]+$/', $g)
);
return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* MCP endpoint (Streamable HTTP), OAuth-protected by Authelia. See issue #111.
*
* Serves /mcp and the RFC 9728 Protected Resource Metadata paths (nginx routes
* all of them here). This is the ONLY file allowed to load vendor/autoload.php.
*
* Identity comes exclusively from the validated access token. Never read
* Remote-User / Remote-* headers or $_SESSION for identity here: this location
* is exempt from Authelia forward-auth at the proxy, so those headers are
* client-controlled on this path.
*/
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
RateLimitMiddleware::apply('api', false);
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
require_once dirname(__DIR__) . '/services/CommentService.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
use Mcp\Server\Session\FileSessionStore;
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
use TinkerTickets\Mcp\ToolCatalog;
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
if (empty($resourceUrl) || empty($issuer)) {
http_response_code(503);
header('Content-Type: application/json');
echo json_encode(['error' => 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']);
exit;
}
$psr17 = new Psr17Factory();
$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
// ServerRequestCreator adds Host both from the URI and from the request
// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which
// the DNS-rebinding check below then rejects. Collapse to the single value the
// client actually sent.
$clientHost = $request->getHeader('Host')[0] ?? '';
if ($clientHost !== '') {
$request = $request->withHeader('Host', $clientHost);
}
// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header
// the client controls. The SDK derives the resource_metadata URL in its 401
// challenge from the request URI, so pin scheme/host/port to the configured
// canonical URL instead of anything the request claims. preserveHost keeps
// the client's real Host header for the DNS-rebinding check below; without
// it withUri() would overwrite Host and make that check a no-op.
$canonical = parse_url($resourceUrl);
$request = $request->withUri(
$request->getUri()
->withScheme($canonical['scheme'])
->withHost($canonical['host'])
->withPort($canonical['port'] ?? null),
true
);
// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to
// Authelia. Outside the webroot on purpose.
$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache'));
$validator = new JwtTokenValidator(
issuer: $issuer,
audience: $resourceUrl,
jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache),
// Authelia puts scopes in an `scp` array, not the standard `scope` string
// (verified in #111 phase 1). With the default, every scope check fails.
scopeClaim: 'scp',
);
$resourcePath = $canonical['path'] ?? '';
$metadata = new ProtectedResourceMetadata(
authorizationServers: [$issuer],
scopesSupported: ['tickets:read', 'tickets:write'],
resource: $resourceUrl,
resourceName: 'Tinker Tickets',
// RFC 9728 path-suffixed form first (used in the WWW-Authenticate
// challenge), plus the root form some clients probe.
metadataPaths: array_values(array_unique([
'/.well-known/oauth-protected-resource' . $resourcePath,
'/.well-known/oauth-protected-resource',
])),
);
$conn = Database::getConnection();
$server = ToolCatalog::register(
Server::builder()
->setServerInfo('Tinker Tickets', '1.0.0')
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
$conn
)->build();
$transport = new StreamableHttpTransport(
$request,
middleware: [
// CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin
// browser calls are refused). Host allowlist: only the canonical
// hostname, which also refuses direct-by-IP access.
new CorsMiddleware(),
new DnsRebindingProtectionMiddleware([$canonical['host']]),
new ProtectedResourceMetadataMiddleware($metadata),
new AuthorizationMiddleware($validator, $metadata),
// Identity comes from the validated token's server-side request
// attributes, never from client-writable JSON-RPC `_meta` (so the
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
new IdentityMiddleware($conn, $psr17, $psr17),
new ToolScopeMiddleware(
$psr17,
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
),
],
);
try {
$response = $server->run($transport);
} catch (\Throwable $e) {
error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage());
$response = $psr17->createResponse(500)
->withHeader('Content-Type', 'application/json')
->withBody($psr17->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32603, 'message' => 'Internal error'],
])));
}
(new SapiEmitter())->emit($response);
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Maps a validated access token to a Tinker Tickets user, applying exactly
* the same rules as the web login (AuthMiddleware): the shared
* AccessPolicy::hasAppAccess() group check, then
* UserModel::syncUserFromAuthelia() to create/update the user row and derive
* is_admin from groups.
*
* Must run after the SDK's AuthorizationMiddleware, which has already
* verified the token's signature, issuer, audience and expiry and attached
* its claims as `oauth.claims` / `oauth.scopes` request attributes.
*/
final class IdentityMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly \mysqli $conn,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$claims = $request->getAttribute('oauth.claims');
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if (!is_array($claims)) {
// Only reachable if the stack is misordered; never serve anonymously.
return $this->deny(500, 'Identity unavailable');
}
$username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
if ($username === '') {
return $this->deny(403, 'Access token has no preferred_username claim');
}
$groupsClaim = $claims['groups'] ?? [];
$groups = implode(',', array_filter(
is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
'is_string'
));
if (!\AccessPolicy::hasAppAccess($groups)) {
return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
}
$user = (new \UserModel($this->conn))->syncUserFromAuthelia(
$username,
is_string($claims['name'] ?? null) ? $claims['name'] : '',
is_string($claims['email'] ?? null) ? $claims['email'] : '',
$groups
);
McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
return $handler->handle($request);
}
private function deny(int $status, string $message): ResponseInterface
{
return $this->responseFactory->createResponse($status)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream(json_encode([
'jsonrpc' => '2.0',
'id' => null,
'error' => ['code' => -32001, 'message' => $message],
])));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace TinkerTickets\Mcp\Auth;
/**
* The Tinker Tickets user an MCP request runs as, plus the token's scopes.
*
* Set once per HTTP request by IdentityMiddleware from the *validated* access
* token, which is held as server-side PSR-7 request attributes. It is
* deliberately NOT read from the JSON-RPC `_meta` field: clients can write
* to `_meta`, and the SDK's OAuthRequestMetaMiddleware only overwrites the
* keys the validator happens to set.
*
* Static state is request-scoped here: PHP-FPM serves one HTTP request per
* process lifecycle, and the whole MCP call (including SDK fibers) runs
* inside it.
*/
final class McpIdentity
{
public const SCOPE_READ = 'tickets:read';
public const SCOPE_WRITE = 'tickets:write';
private static ?array $user = null;
/** @var list<string> */
private static array $scopes = [];
/**
* @param array<string, mixed> $user
* @param list<string> $scopes
*/
public static function set(array $user, array $scopes): void
{
self::$user = $user;
self::$scopes = $scopes;
}
/**
* @return array<string, mixed>
*/
public static function user(): array
{
if (self::$user === null) {
// Unreachable if the middleware stack is intact; fail closed.
throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
}
return self::$user;
}
/** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
public static function canRead(array $scopes): bool
{
return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
}
public static function canWrite(array $scopes): bool
{
return in_array(self::SCOPE_WRITE, $scopes, true);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace TinkerTickets\Mcp\Auth;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TinkerTickets\Mcp\ToolCatalog;
/**
* Per-operation scope enforcement, before anything is dispatched.
*
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
* need only a valid token.
* - tools/call on a write tool needs tickets:write.
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
* tickets:write implies.
*
* Denials use the MCP spec's step-up challenge: HTTP 403 +
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
* the request needs in one go.
*/
final class ToolScopeMiddleware implements MiddlewareInterface
{
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
public function __construct(
private readonly ResponseFactoryInterface $responseFactory,
private readonly string $resourceMetadataUrl,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if ($request->getMethod() !== 'POST') {
return $handler->handle($request);
}
$payload = json_decode((string)$request->getBody(), true);
$request->getBody()->rewind();
if (!is_array($payload)) {
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
}
$messages = array_is_list($payload) ? $payload : [$payload];
$needWrite = false;
$needRead = false;
foreach ($messages as $message) {
if (!is_array($message) || !is_string($message['method'] ?? null)) {
continue; // responses to server->client requests carry no method
}
$method = $message['method'];
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
continue;
}
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
$needWrite = true;
} else {
$needRead = true;
}
}
$scopes = $request->getAttribute('oauth.scopes') ?? [];
if ($needWrite && !McpIdentity::canWrite($scopes)) {
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
}
if ($needRead && !McpIdentity::canRead($scopes)) {
return $this->insufficient(
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
'This operation requires the tickets:read scope.'
);
}
return $handler->handle($request);
}
private function insufficient(string $scope, string $description): ResponseInterface
{
return $this->responseFactory->createResponse(403)->withHeader(
'WWW-Authenticate',
sprintf(
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
$scope,
$this->resourceMetadataUrl,
$description
)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace TinkerTickets\Mcp;
use Mcp\Schema\ToolAnnotations;
use Mcp\Server\Builder;
use TinkerTickets\Mcp\Tools\TicketReadTools;
use TinkerTickets\Mcp\Tools\TicketWriteTools;
/**
* The single list of MCP tools: registration, plus which ones need the
* tickets:write scope (read by ToolScopeMiddleware). Keeping both here means
* a new write tool can't be registered without also being scope-gated.
*/
final class ToolCatalog
{
/** Tool names that mutate data and require tickets:write. */
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket'];
public static function isWriteTool(string $name): bool
{
return in_array($name, self::WRITE_TOOLS, true);
}
public static function register(Builder $builder, \mysqli $conn): Builder
{
$read = new TicketReadTools($conn);
$write = new TicketWriteTools($conn);
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
// Writes change tickets (and notify people) but never delete anything.
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
return $builder
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Read-only tools. Everything goes through the same model methods and
* visibility checks the web UI uses, as the signed-in user, so MCP can never
* show more than that user could see in their browser.
*/
final class TicketReadTools
{
private const MAX_COMMENTS = 200;
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Search and list tickets you can see (same visibility rules as the web UI).
*
* @param string|null $query Free-text search over ticket titles and descriptions.
* @param string|null $status Comma-separated statuses, e.g. "Open,In Progress". Omit for every status except Closed; use "all" for every status.
* @param int|null $priority Exact priority: 1 (critical) to 5 (minimal).
* @param string|null $category Exact category name.
* @param string|null $assignee "me", "unassigned", or a username.
* @param int $page Page number, starting at 1.
* @param int $limit Tickets per page, 1-50.
*
* @return array<string, mixed>
*/
public function searchTickets(
?string $query = null,
?string $status = null,
?int $priority = null,
?string $category = null,
?string $assignee = null,
int $page = 1,
int $limit = 20,
): array {
$user = McpIdentity::user();
$allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if ($status === null || trim($status) === '') {
$statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
} elseif (strtolower(trim($status)) === 'all') {
$statusFilter = null;
} else {
$requested = array_map('trim', explode(',', $status));
$unknown = array_diff($requested, $allStatuses);
if ($unknown) {
throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
. '. Valid statuses: ' . implode(', ', $allStatuses));
}
$statusFilter = implode(',', $requested);
}
$filters = [];
if ($priority !== null) {
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
$filters['priority_min'] = $priority;
$filters['priority_max'] = $priority;
}
if ($assignee !== null && trim($assignee) !== '') {
$filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
}
$page = max(1, $page);
$limit = min(50, max(1, $limit));
$result = (new \TicketModel($this->conn))->getAllTickets(
$page,
$limit,
$statusFilter,
'updated_at',
'desc',
($category !== null && trim($category) !== '') ? trim($category) : null,
null,
($query !== null && trim($query) !== '') ? trim($query) : null,
$filters,
$user
);
return [
'tickets' => array_map([$this, 'summarize'], $result['tickets']),
'page' => $result['current_page'],
'pages' => $result['pages'],
'total' => $result['total'],
];
}
/**
* Get one ticket's full details and its comments.
*
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
* @param bool $include_comments Include the ticket's comments, newest first.
*
* @return array<string, mixed>
*/
public function getTicket(string $ticket_id, bool $include_comments = true): array
{
$user = McpIdentity::user();
$ticketModel = new \TicketModel($this->conn);
$ticketId = trim($ticket_id);
$ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
// Same "not found" for missing and not-visible, so a restricted
// ticket's existence isn't revealed.
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$details = $this->summarize($ticket) + [
'description' => $ticket['description'] ?? '',
'visibility_groups' => $ticket['visibility_groups'] ?? null,
'closed_at' => $ticket['closed_at'] ?? null,
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
];
if ($include_comments) {
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
$details['comment_count'] = count($comments);
$details['comments'] = array_map(fn(array $c) => [
'comment_id' => (int)$c['comment_id'],
'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
'created_at' => $c['created_at'] ?? null,
'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
'text' => $c['comment_text'] ?? '',
], array_slice($comments, 0, self::MAX_COMMENTS));
if (count($comments) > self::MAX_COMMENTS) {
$details['comments_truncated'] = true;
}
}
return $details;
}
/**
* @param array<string, mixed> $user
*/
private function resolveAssigneeFilter(string $assignee, array $user): int|string
{
$lower = strtolower($assignee);
if ($lower === 'me') {
return (int)$user['user_id'];
}
if ($lower === 'unassigned') {
return 'unassigned';
}
$match = (new \UserModel($this->conn))->getUserByUsername($assignee);
if (!$match) {
throw new ToolCallException("Unknown user: {$assignee}");
}
return (int)$match['user_id'];
}
/**
* @param array<string, mixed> $t
*
* @return array<string, mixed>
*/
private function summarize(array $t): array
{
return [
'ticket_id' => (string)$t['ticket_id'],
'title' => $t['title'] ?? '',
'status' => $t['status'] ?? null,
'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
'category' => $t['category'] ?? null,
'type' => $t['type'] ?? null,
'visibility' => $t['visibility'] ?? 'public',
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
'created_at' => $t['created_at'] ?? null,
'updated_at' => $t['updated_at'] ?? null,
'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
];
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
namespace TinkerTickets\Mcp\Tools;
use Mcp\Exception\ToolCallException;
use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Write tools. Each one is a thin adapter over the exact code path the web UI
* uses (TicketCreationService, CommentService, ApiTicketController,
* AssignmentService), run as the signed-in user, so permissions, workflow
* rules, audit entries, notifications and stats-cache invalidation are
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
*/
final class TicketWriteTools
{
private const VISIBILITIES = ['public', 'internal', 'confidential'];
public function __construct(private readonly \mysqli $conn)
{
}
/**
* Create a new ticket as you. It starts in the Open status.
*
* @param string $title Short summary of the issue.
* @param string $description Full description (markdown supported).
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
*
* @return array<string, mixed>
*/
public function createTicket(
string $title,
string $description,
int $priority = 4,
string $category = 'General',
string $type = 'Issue',
string $visibility = 'public',
?string $visibility_groups = null,
?string $assignee = null,
): array {
$user = McpIdentity::user();
// The web form constrains these with dropdowns; an API caller can send
// anything, so validate them here before handing off.
if ($priority < 1 || $priority > 5) {
throw new ToolCallException('priority must be between 1 and 5');
}
if (!in_array($visibility, self::VISIBILITIES, true)) {
throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES));
}
$assignedTo = null;
if ($assignee !== null && trim($assignee) !== '') {
$assignedTo = $this->resolveUserId(trim($assignee), $user);
}
$result = \TicketCreationService::create($this->conn, $user, [
'title' => $title,
'description' => $description,
'priority' => (string)$priority,
'category' => $category,
'type' => $type,
'status' => 'Open',
'visibility' => $visibility,
'visibility_groups' => $visibility_groups,
'assigned_to' => $assignedTo,
]);
if (!$result['success']) {
throw new ToolCallException($result['error']);
}
return [
'ticket_id' => (string)$result['ticket_id'],
'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']),
];
}
/**
* Post a comment on a ticket as you. @username mentions notify that user.
*
* @param string $ticket_id The ticket ID.
* @param string $text The comment text.
* @param bool $markdown Render the comment as markdown.
* @param int|null $reply_to comment_id of the comment you are replying to, if any.
*
* @return array<string, mixed>
*/
public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array
{
$user = McpIdentity::user();
$result = \CommentService::addComment($this->conn, $user, [
'ticket_id' => $ticket_id,
'comment_text' => $text,
'markdown_enabled' => $markdown,
'parent_comment_id' => $reply_to,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return [
'comment_id' => (int)$result['comment_id'],
'ticket_id' => trim($ticket_id),
'mentions' => $result['mentions'] ?? [],
];
}
/**
* Change a ticket's status. Transitions follow the Workflow Designer rules;
* some transitions (e.g. closing) require a comment, which is posted as the reason.
*
* @param string $ticket_id The ticket ID.
* @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed".
* @param string|null $comment Reason for the change; required for transitions that need one.
*
* @return array<string, mixed>
*/
public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array
{
$user = McpIdentity::user();
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
if (!in_array($status, $validStatuses, true)) {
throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses));
}
$ticketId = trim($ticket_id);
if (!ctype_digit($ticketId)) {
throw new ToolCallException("Ticket {$ticketId} not found");
}
$controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user);
$data = ['status' => $status];
if ($comment !== null && trim($comment) !== '') {
$data['comment'] = $comment;
$data['markdown_enabled'] = true;
}
$result = $controller->update($ticketId, $data);
if (empty($result['success'])) {
if (!empty($result['requires_comment'])) {
throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.');
}
throw new ToolCallException($this->errorMessage($result, $ticketId));
}
// api/update_ticket.php invalidates the dashboard stats cache after a
// successful update (outside the controller); do the same here.
(new \StatsModel($this->conn))->invalidateCache();
return ['ticket_id' => $ticketId, 'status' => $result['status']];
}
/**
* Assign a ticket to someone, or unassign it. Only admins, the ticket's
* creator, or its current assignee may do this.
*
* @param string $ticket_id The ticket ID.
* @param string $assignee Username to assign to, "me", or "unassigned".
*
* @return array<string, mixed>
*/
public function assignTicket(string $ticket_id, string $assignee): array
{
$user = McpIdentity::user();
$target = trim($assignee);
$assignedTo = strtolower($target) === 'unassigned' || $target === ''
? null
: $this->resolveUserId($target, $user);
$result = \AssignmentService::assign($this->conn, $user, [
'ticket_id' => $ticket_id,
'assigned_to' => $assignedTo,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $ticket_id));
}
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
}
/**
* @param array<string, mixed> $user
*/
private function resolveUserId(string $username, array $user): int
{
if (strtolower($username) === 'me') {
return (int)$user['user_id'];
}
$match = (new \UserModel($this->conn))->getUserByUsername($username);
if (!$match) {
throw new ToolCallException("Unknown user: {$username}");
}
return (int)$match['user_id'];
}
/**
* Map a shared-service failure to a tool error. A ticket the user can't
* see reads as "not found" (like get_ticket), never "access denied", so a
* restricted ticket's existence isn't revealed.
*
* @param array<string, mixed> $result
*/
private function errorMessage(array $result, string $ticketId): string
{
$error = (string)($result['error'] ?? 'Request failed');
if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) {
return 'Ticket ' . trim($ticketId) . ' not found';
}
return $error;
}
}
+2 -15
View File
@@ -263,21 +263,8 @@ class AuthMiddleware
*/
private function checkGroupAccess($groups)
{
if (empty($groups)) {
return false;
}
// Check for admin or employee group membership
// Filter to safe characters only to prevent header injection attacks
$userGroups = array_filter(
array_map('trim', explode(',', strtolower($groups))),
function ($g) {
return preg_match('/^[a-z0-9_\-]+$/', $g);
}
);
$requiredGroups = ['admin', 'employee'];
return !empty(array_intersect($userGroups, $requiredGroups));
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
}
/**
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* Assigning / unassigning a ticket: access check, permission (admin, creator,
* or current assignee), audit log, optional Matrix assignment notification,
* stats-cache invalidation.
*
* Shared by the web UI (api/assign_ticket.php) and the MCP assign_ticket tool
* so both run one code path (tinker_tickets#111). Extracted verbatim from
* assign_ticket.php. Returns result arrays; failures carry 'http_status'.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class AssignmentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, is_admin, ...)
* @param array $data ticket_id, assigned_to (user_id; null/'' to unassign)
*/
public static function assign(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$assignedTo = $data['assigned_to'] ?? null;
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
}
$ticketId = $ticketIdRaw;
$ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists and user can access it
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
if ($success) {
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
}
} else {
// Validate assigned_to is a valid user ID
$assignedTo = (int)$assignedTo;
$targetUser = $userModel->getUserById($assignedTo);
if (!$targetUser) {
return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
}
// Assign ticket
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
if ($success) {
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
$assigneeMatrix = isset($targetUser['username'])
? SynapseHelper::resolveUsername($targetUser['username'])
: null;
NotificationHelper::sendAssignmentNotification(
$ticketId,
$ticket['title'] ?? "Ticket #{$ticketId}",
$assigneeName,
$assigneeMatrix,
$changedByDisplay,
$ticket['visibility'] ?? 'public'
);
}
}
}
if (!$success) {
return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
}
(new StatsModel($conn))->invalidateCache();
return ['success' => true];
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Adding a comment to a ticket: validation, access check, reply-parent check,
* @mention extraction (audit-logged, and notified only to mentioned users who
* can see the ticket), comment + watcher notifications.
*
* Shared by the web UI (api/add_comment.php) and the MCP add_comment tool so
* both run one code path (tinker_tickets#111). Extracted verbatim from
* add_comment.php. Returns result arrays; on validation failure the array
* carries 'http_status' for HTTP callers. Callers own sessions/CSRF/responses.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CommentModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
class CommentService
{
/**
* @param array $currentUser Authenticated user row (user_id, username, display_name, groups, is_admin)
* @param array $data ticket_id, comment_text, optional markdown_enabled / parent_comment_id
*/
public static function addComment(mysqli $conn, array $currentUser, array $data): array
{
$userId = $currentUser['user_id'];
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
return ['success' => false, 'error' => 'Invalid ticket ID', 'http_status' => 400];
}
// Reject empty/whitespace-only comments
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
if ($commentTextRaw === '') {
return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400];
}
// Persist the trimmed text (not the raw client value) — matches update_comment.php
// and keeps stored comment_text free of leading whitespace that could shift a
// markdown-enabled comment's first line out of column 0 on reload.
$data['comment_text'] = $commentTextRaw;
// Never trust a client-supplied display name — always attribute the comment to
// the authenticated user.
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
// Verify user can access the ticket before allowing a comment
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
}
// Initialize models
$commentModel = new CommentModel($conn);
$auditLog = new AuditLogModel($conn);
// If replying, the parent comment must belong to this same (accessible) ticket.
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400];
}
}
// Extract @mentions from comment text
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
$mentionedUsers = [];
if (!empty($mentions)) {
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
}
// Add comment with user tracking
$result = $commentModel->addComment($ticketId, $data, $userId);
// Log comment creation to audit log
if ($result['success'] && isset($result['comment_id'])) {
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
// Log mentions to audit log
foreach ($mentionedUsers as $mentionedUser) {
$auditLog->log(
$userId,
'mention',
'user',
(string)$mentionedUser['user_id'],
[
'ticket_id' => $ticketId,
'comment_id' => $result['comment_id'],
'mentioned_username' => $mentionedUser['username']
]
);
}
// Matrix notifications
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
$commentText = $data['comment_text'] ?? '';
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
$ticketVisibility = $ticket['visibility'] ?? 'public';
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
// Only notify mentioned users who actually have access to this ticket;
// otherwise a mention would DM them the ticket's title and comment text
// even though canUserAccessTicket() would deny them the ticket itself.
$accessibleMentionedUsers = array_filter(
$mentionedUsers,
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
);
if (!empty($accessibleMentionedUsers)) {
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
if (!empty($mentionedMatrixIds)) {
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
}
}
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
NotificationHelper::sendCommentNotification(
$ticketId,
$ticketTitle,
$commentText,
$authorDisplay,
$ticketVisibility !== 'public',
$ticketVisibility
);
}
// Notify watchers of the new comment
NotificationHelper::notifyWatchers(
$conn,
$ticketId,
$ticketTitle,
'comment_added',
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
(int)$userId,
$ticketVisibility
);
// Add mentioned users to result for frontend
$result['mentions'] = array_map(function ($u) {
return $u['username'];
}, $mentionedUsers);
}
// Add user info to result for frontend avatar rendering
if ($result['success']) {
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
$result['user_id'] = $userId;
}
return $result;
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Creating a ticket as a user: required-field and required-custom-field
* validation, TicketModel::createTicket (which enforces visibility rules),
* audit log, stats-cache invalidation, custom field values, optional
* "duplicates" link, and the new-ticket Matrix notification.
*
* Shared by the web UI (TicketController::create) and the MCP create_ticket
* tool so both run one code path (tinker_tickets#111). Extracted from
* TicketController::create with the same checks, order and error messages.
* Callers own request parsing, CSRF, and rendering.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/models/StatsModel.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
class TicketCreationService
{
/**
* @param array $currentUser Authenticated user row
* @param array $input title, description, priority, category, type, status,
* visibility, visibility_groups (string or list), assigned_to,
* custom_fields (field_id => value), link_duplicate_of
*
* @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...]
*/
public static function create(mysqli $conn, array $currentUser, array $input): array
{
$userId = $currentUser['user_id'] ?? null;
// Handle visibility groups (a list from the web form's checkboxes, or a string)
$visibilityGroups = null;
if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) {
$visibilityGroups = implode(',', array_map('trim', $input['visibility_groups']));
} elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') {
$visibilityGroups = trim($input['visibility_groups']);
}
// Honor the posted status, validated against the app's canonical list
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
$status = $input['status'] ?? 'Open';
if (!in_array($status, $validStatuses, true)) {
$status = 'Open';
}
$ticketData = [
'title' => trim($input['title'] ?? ''),
'description' => $input['description'] ?? '',
'priority' => $input['priority'] ?? '4',
'category' => $input['category'] ?? 'General',
'type' => $input['type'] ?? 'Issue',
'status' => $status,
'visibility' => $input['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null
];
if ($ticketData['title'] === '') {
return ['success' => false, 'error' => 'Title is required'];
}
if (trim($ticketData['description']) === '') {
return ['success' => false, 'error' => 'Description is required'];
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$customFieldModel = new CustomFieldModel($conn);
$allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true);
$submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
return ['success' => false, 'error' => $def['field_label'] . ' is required'];
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = (new TicketModel($conn))->createTicket($ticketData, $userId);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
// Log ticket creation to audit log
if ($userId) {
(new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData);
}
// Ticket counts changed — invalidate the cached dashboard stats
(new StatsModel($conn))->invalidateCache();
// Persist custom field values for the fields applicable to this ticket's category
if (!empty($customFieldsToSave)) {
$customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested
$linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? ''));
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
VALUES (?, ?, 'duplicates', ?)";
$depStmt = $conn->prepare($depSql);
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
$depStmt->execute();
$depStmt->close();
}
// Send Matrix notification for new ticket
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
return ['success' => true, 'ticket_id' => $result['ticket_id']];
}
}