50 - Scope Scenario reads to the authenticated owner

This commit is contained in:
myrmidex 2026-06-25 19:59:40 +02:00
parent e4b1506ac5
commit 064805eb09
2 changed files with 186 additions and 4 deletions

View file

@ -0,0 +1,69 @@
<?php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Scenario;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Scopes every Scenario read to the authenticated owner by appending
* `WHERE owner = :currentUser`. Non-owned rows become unresolvable, so
* API Platform's provider 404s naturally no 403, no existence oracle.
*/
final class ScenarioOwnerExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->addOwnerWhere($queryBuilder, $queryNameGenerator, $resourceClass);
}
private function addOwnerWhere(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// This extension fires for every resource's queries — only act on Scenario.
if (Scenario::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$param = $queryNameGenerator->generateParameterName('currentUser');
$queryBuilder
->andWhere(\sprintf('%s.owner = :%s', $rootAlias, $param))
->setParameter($param, $user);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Tests\Functional; namespace App\Tests\Functional;
use App\Entity\Scenario;
use App\Entity\User; use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
@ -84,10 +85,9 @@ final class ScenarioOwnerApiTest extends WebTestCase
$this->em->clear(); $this->em->clear();
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Holiday Fund']); $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']);
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.'); self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
self::assertTrue( self::assertTrue(
$this->caller->getId()->equals($persisted->getOwner()->getId()), $this->caller->getId()->equals($persisted->getOwner()->getId()),
'The persisted Scenario must be owned by the authenticated caller, not left null or assigned elsewhere.', 'The persisted Scenario must be owned by the authenticated caller, not left null or assigned elsewhere.',
@ -119,10 +119,9 @@ final class ScenarioOwnerApiTest extends WebTestCase
$this->em->clear(); $this->em->clear();
$persisted = $this->em->getRepository(\App\Entity\Scenario::class)->findOneBy(['name' => 'Spoofed Owner Fund']); $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Spoofed Owner Fund']);
self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.'); self::assertNotNull($persisted, 'A successful POST /api/scenarios must persist the new Scenario.');
self::assertNotNull($persisted->getOwner(), 'The persisted Scenario must have an owner set.');
self::assertTrue( self::assertTrue(
$this->caller->getId()->equals($persisted->getOwner()->getId()), $this->caller->getId()->equals($persisted->getOwner()->getId()),
'A client-supplied owner in the request body must be ignored — the owner must always be the authenticated caller.', 'A client-supplied owner in the request body must be ignored — the owner must always be the authenticated caller.',
@ -133,6 +132,120 @@ final class ScenarioOwnerApiTest extends WebTestCase
); );
} }
public function testGetItemOnAScenarioOwnedBySomeoneElseReturnsNotFound(): void
{
$othersScenario = new Scenario();
$othersScenario->setName('Someone Else\'s Fund');
$othersScenario->setOwner($this->otherUser);
$this->em->persist($othersScenario);
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
404,
$this->client->getResponse()->getStatusCode(),
'GET /api/scenarios/{id} for a scenario owned by another user must return 404, not 403 — no existence oracle for scenarios you do not own.',
);
}
public function testGetCollectionOnlyReturnsScenariosOwnedByTheCaller(): void
{
$mine = new Scenario();
$mine->setName('My Fund');
$mine->setOwner($this->caller);
$this->em->persist($mine);
$othersScenario = new Scenario();
$othersScenario->setName('Someone Else\'s Fund');
$othersScenario->setOwner($this->otherUser);
$this->em->persist($othersScenario);
$this->em->flush();
$this->login();
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'GET /api/scenarios must return 200 for an authenticated user.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey(
'member',
$body,
'A Hydra collection response must expose its items under the member key.',
);
$names = array_column($body['member'], 'name');
self::assertContains(
'My Fund',
$names,
'GET /api/scenarios must include scenarios owned by the authenticated caller.',
);
self::assertNotContains(
'Someone Else\'s Fund',
$names,
'GET /api/scenarios must NOT include scenarios owned by other users.',
);
}
public function testAnonymousGetCollectionIsRejectedRatherThanReturningUnfilteredScenarios(): void
{
// The ownership extension no-ops for anonymous requests (no user to scope to),
// so the resource-level `is_granted('ROLE_USER')` security MUST reject the
// request before the query runs. If that wall ever relaxes, an anonymous caller
// would receive an unfiltered listing — this test fails closed if that happens.
$othersScenario = new Scenario();
$othersScenario->setName('Someone Else\'s Fund');
$othersScenario->setOwner($this->otherUser);
$this->em->persist($othersScenario);
$this->em->flush();
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/scenarios without authentication must be rejected by the security layer, not served an unfiltered collection.',
);
}
public function testAnonymousGetItemIsRejectedRatherThanResolvingScenario(): void
{
$othersScenario = new Scenario();
$othersScenario->setName('Someone Else\'s Fund');
$othersScenario->setOwner($this->otherUser);
$this->em->persist($othersScenario);
$this->em->flush();
$this->client->request('GET', '/api/scenarios/'.$othersScenario->getId(), server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
self::assertSame(
401,
$this->client->getResponse()->getStatusCode(),
'GET /api/scenarios/{id} without authentication must be rejected by the security layer.',
);
}
public function testPostWithMissingNameIsStillRejectedAsUnprocessable(): void public function testPostWithMissingNameIsStillRejectedAsUnprocessable(): void
{ {
$this->login(); $this->login();