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
60 lines
1.8 KiB
PHP
60 lines
1.8 KiB
PHP
<?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);
|
|
}
|
|
}
|