50 - Scope Scenario reads to the authenticated owner
This commit is contained in:
parent
e4b1506ac5
commit
064805eb09
2 changed files with 186 additions and 4 deletions
69
src/Doctrine/ScenarioOwnerExtension.php
Normal file
69
src/Doctrine/ScenarioOwnerExtension.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue