Files
tinker_tickets/mcp/src/Auth/ToolScopeMiddleware.php
T
jaredandClaude Opus 5.5 65deedc295
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
Add MCP identity mapping and read tools (#111, phase 4)
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

92 lines
3.3 KiB
PHP

<?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
)
);
}
}