fix(security) : add role checks on Gitea API resources and all MCP tools

- GiteaBranch, GiteaBranchName, GiteaPullRequest: require ROLE_USER
- All 22 MCP tools: require ROLE_USER (ROLE_ADMIN for users/clients listing)

Tickets: T-002, T-007

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-17 15:23:06 +01:00
parent 5db6b1e2b0
commit e0dfcbdbf8
25 changed files with 163 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branches',
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
security: "is_granted('ROLE_USER')",
),
new Post(
uriTemplate: '/tasks/{taskId}/gitea/branches',
@@ -24,6 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['gitea_branch:read']],
provider: GiteaBranchProvider::class,
processor: GiteaBranchProcessor::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
normalizationContext: ['groups' => ['gitea_branch_name:read']],
provider: GiteaBranchNameProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
normalizationContext: ['groups' => ['gitea_pr:read']],
provider: GiteaPullRequestProvider::class,
security: "is_granted('ROLE_USER')",
),
],
)]

View File

@@ -10,6 +10,8 @@ use App\Repository\ClientRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -19,6 +21,7 @@ class CreateProjectTool
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -28,6 +31,10 @@ class CreateProjectTool
?string $color = null,
?int $clientId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = new Project();
$project->setName($name);
$project->setCode($code);

View File

@@ -9,6 +9,8 @@ use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -18,10 +20,15 @@ class GetProjectTool
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($id);
if (null === $project) {

View File

@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\Project;
use App\Mcp\Tool\Serializer;
use App\Repository\ProjectRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
class ListProjectsTool
{
public function __construct(
private readonly ProjectRepository $projectRepository,
private readonly Security $security,
) {}
public function __invoke(bool $archived = false): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$projects = $this->projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']);
return json_encode(array_map(Serializer::project(...), $projects));

View File

@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -20,6 +22,7 @@ class UpdateProjectTool
private readonly ProjectRepository $projectRepository,
private readonly ClientRepository $clientRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
@@ -31,6 +34,10 @@ class UpdateProjectTool
?int $clientId = null,
?bool $archived = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($id);
if (null === $project) {

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
use App\Repository\ClientRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
class ListClientsTool
{
public function __construct(
private readonly ClientRepository $clientRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
return json_encode(array_map(fn ($client) => [

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\Reference;
use App\Repository\UserRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
return json_encode(array_map(fn ($user) => [

View File

@@ -17,6 +17,8 @@ use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -33,6 +35,7 @@ class CreateTaskTool
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -46,6 +49,10 @@ class CreateTaskTool
?int $groupId = null,
?array $tagIds = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
@@ -54,7 +61,6 @@ class CreateTaskTool
$task = new Task();
$task->setProject($project);
$task->setTitle($title);
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
if (null !== $description) {
$task->setDescription($description);
@@ -104,8 +110,11 @@ class CreateTaskTool
}
}
$this->entityManager->persist($task);
$this->entityManager->flush();
$this->entityManager->wrapInTransaction(function () use ($task, $project): void {
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
$this->entityManager->persist($task);
$this->entityManager->flush();
});
return json_encode([
'id' => $task->getId(),

View File

@@ -8,6 +8,8 @@ use App\Repository\TaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -17,10 +19,15 @@ class DeleteTaskTool
public function __construct(
private readonly TaskRepository $taskRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id);
if (null === $task) {

View File

@@ -8,6 +8,8 @@ use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -16,10 +18,15 @@ class GetTaskTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id);
if (null === $task) {

View File

@@ -7,12 +7,15 @@ namespace App\Mcp\Tool\Task;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
class ListTasksTool
{
public function __construct(
private readonly TaskRepository $taskRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -25,6 +28,10 @@ class ListTasksTool
bool $archived = false,
int $limit = 100,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$limit = min($limit, 200);
$qb = $this->taskRepository->createQueryBuilder('t')

View File

@@ -15,6 +15,8 @@ use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -30,6 +32,7 @@ class UpdateTaskTool
private readonly TaskGroupRepository $taskGroupRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly UserRepository $userRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -44,6 +47,10 @@ class UpdateTaskTool
?array $tagIds = null,
?bool $archived = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($id);
if (null === $task) {

View File

@@ -10,6 +10,8 @@ use App\Repository\ProjectRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -19,6 +21,7 @@ class CreateGroupTool
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProjectRepository $projectRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -27,6 +30,10 @@ class CreateGroupTool
?string $description = null,
?string $color = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$project = $this->projectRepository->find($projectId);
if (null === $project) {
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskEffortRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
class ListEffortsTool
{
public function __construct(
private readonly TaskEffortRepository $taskEffortRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($e) => [

View File

@@ -7,16 +7,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Mcp\Tool\Serializer;
use App\Repository\TaskGroupRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
class ListGroupsTool
{
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly Security $security,
) {}
public function __invoke(?int $projectId = null, bool $archived = false): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$criteria = ['archived' => $archived];
if (null !== $projectId) {
$criteria['project'] = $projectId;

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskPriorityRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
class ListPrioritiesTool
{
public function __construct(
private readonly TaskPriorityRepository $taskPriorityRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($p) => [

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskStatusRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
class ListStatusesTool
{
public function __construct(
private readonly TaskStatusRepository $taskStatusRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($s) => [

View File

@@ -6,16 +6,23 @@ namespace App\Mcp\Tool\TaskMeta;
use App\Repository\TaskTagRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
class ListTagsTool
{
public function __construct(
private readonly TaskTagRepository $taskTagRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
return json_encode(array_map(fn ($t) => [

View File

@@ -9,6 +9,8 @@ use App\Repository\TaskGroupRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -18,6 +20,7 @@ class UpdateGroupTool
public function __construct(
private readonly TaskGroupRepository $taskGroupRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
@@ -27,6 +30,10 @@ class UpdateGroupTool
?string $color = null,
?bool $archived = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$group = $this->taskGroupRepository->find($id);
if (null === $group) {

View File

@@ -16,6 +16,8 @@ use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -30,6 +32,7 @@ class CreateTimeEntryTool
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -43,6 +46,10 @@ class CreateTimeEntryTool
?string $description = null,
?int $clientTicketId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = $this->userRepository->find($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));

View File

@@ -8,6 +8,8 @@ use App\Repository\TimeEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -17,10 +19,15 @@ class DeleteTimeEntryTool
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {

View File

@@ -8,12 +8,15 @@ use App\Mcp\Tool\Serializer;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly Security $security,
) {}
public function __invoke(
@@ -25,6 +28,10 @@ class ListTimeEntriesTool
?string $endDate = null,
int $limit = 100,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$limit = min($limit, 200);
$qb = $this->timeEntryRepository->createQueryBuilder('te')

View File

@@ -14,6 +14,8 @@ use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
@@ -27,6 +29,7 @@ class UpdateTimeEntryTool
private readonly TaskTagRepository $taskTagRepository,
private readonly ClientTicketRepository $clientTicketRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(
@@ -40,6 +43,10 @@ class UpdateTimeEntryTool
?string $description = null,
?int $clientTicketId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
if (null === $entry) {