Set up framework for testing security in API Platform (Symfony)

Filip Horvat
8 min readFeb 26, 2023

--

The goal of this article is to set up your starting point for your framework with which you will test security in your API Platform project. This article is for the developers who have some experience with the API Platform and want to see some other concepts and ideas.

Some of the prerequisites for this example are:

  • Installed PHP with mysql and sqlite extensions
  • Installed Symfony and API Platform
  • Installed doctrine bundle
  • Installed testing packages for symfony

For our example we will use 2 basic doctrine entities with minimal attributes:

  • “User” with id, email and password
  • “Offer” with id and content
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column]
private ?string $email = null;

#[ORM\Column]
private ?string $password = null;

#[ORM\Column(type: 'json')]
private $roles = [];

public function getId(): ?int
{
return $this->id;
}

public function getEmail(): ?string
{
return $this->email;
}

public function setEmail(?string $email): void
{
$this->email = $email;
}

public function getRoles(): array
{
return $this->roles;
}

public function setRoles(array $roles): void
{
$this->roles = $roles;
}

public function getPassword(): ?string
{
return $this->password;
}

public function setPassword(?string $password): void
{
$this->password = $password;
}

public function eraseCredentials()
{
// TODO: Implement eraseCredentials() method.
}

public function getUserIdentifier(): string
{
return $this->email;
}
}
<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Offer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column]
private ?string $content = null;

public function getId(): ?int
{
return $this->id;
}

public function getContent(): ?string
{
return $this->content;
}

public function setContent(?string $content): void
{
$this->content = $content;
}
}

There is nothing special about those entities, only that we implemented UserInterface and PasswordAuthenticatedUserInterface interfaces for the user entity. UserInterface is used for creating JWT token and PasswordAuthenticatedUserInterface for hashing passwords.

In this example we will also use lexik/jwt-authentication-bundle for the authenticate users with the JWT token.

Security config is for our app is:

security:
password_hashers:
App\Entity\User: 'auto'
providers:
users:
entity:
class: App\Entity\User
property: email

firewalls:
api:
pattern: ^/api/
stateless: true
provider: users
jwt: ~

We are will also use sqlite database for testing so we need to set up config for that:

when@test:
doctrine:
dbal:
connections:
default:
driver: 'pdo_sqlite'
url: '%env(resolve:DATABASE_URL)%'

in .env.test

DATABASE_URL=sqlite:///var/test.db

We will have 3 user types:

  • User which is not authenticated
  • User which is authenticated without any role
  • User which is authenticated with role ROLE_SUPER_ADMIN

And test 3 GET API endpoints on offer entity:

  • /first/{OFFER_ID} — can be accessed by anyone even unauthenticated
  • second/{OFFER_ID} — can be accessed only by logged in users(no matter if they have any role)
  • third/{OFFER_ID} — can be accessed only by a user with role ROLE_SUPER_ADMIN
#[ApiResource(
operations: [
new Get(
uriTemplate: '/first/{id}',
security: "is_granted('PUBLIC_ACCESS')"
),
new Get(
uriTemplate: '/second/{id}',
security: "is_granted('IS_AUTHENTICATED_FULLY')"
),
new Get(
uriTemplate: '/third/{id}',
security: "is_granted('ROLE_SUPER_ADMIN')"
),
],
)]

For each API endpoint security is defined by the API Platform expression language. The method is_granted is from the symfony and it will check if the user has a role which we defined, but there are some special attributes which you can use with is_granted like IS_AUTHENTICATED_FULLY which will check if the user is just authenticated, and PUBLIC_ACCESS which will allow access for all users, even if there are not authenticated.

Now we will create a starting plain test class in tests/Entity/OfferTest.php which will extend base ApiTestCase from the API Platform:

<?php

namespace App\Tests\Entity;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;

class OfferTest extends ApiTestCase
{
public function testGet(): void
{
//TODO
}
}

In the setUp() method we will add few handy pieces of code:

private Client $client;
private ContainerInterface $container;
private EntityManagerInterface $entityManager;

protected function setUp(): void
{
$this->client = static::createClient();

$this->container = static::getContainer();

$this->entityManager = $this->container->get('doctrine')->getManager();

$this->initDatabase(self::$kernel);

parent::setUp();
}

Our sqlite database will be stored in var/test.db and before each test database will be deleted and recreated with initDatabase:

private function initDatabase(KernelInterface $kernel): void
{
$filesystem = new Filesystem();
$filesystem->remove('var/test.db');

$metaData = $this->entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($this->entityManager);
$schemaTool->updateSchema($metaData);
}

For the testing we are will need one offer to test and 2 users, one without roles and one with role ROLE_SUPER_ADMIN

$offer = $this->createOffer();
$userWithoutRole = $this->createUserWithRole();
$userWithRoleSuperAdmin = $this->createUserWithRole('ROLE_SUPER_ADMIN');

Function for creating offer is standard, but function for creating a user with role is very handy and reusable:

private function createOffer()
{
$container = self::getContainer();
$offer = new Offer();
$offer->setContent('test');
$this->entityManager->persist($offer);
$this->entityManager->flush();

return $offer;
}

private function createUserWithRole($roleName = null)
{
$container = self::getContainer();

$hasher = $container->get('security.user_password_hasher');

$user = new User();
$user->setEmail(strtolower($roleName ?? 'no_role') . '@test.com');
$user->setPassword($hasher->hashPassword($user, 'secret'));
if ($roleName) {
$user->setRoles([$roleName]);
}
$this->entityManager->persist($user);
$this->entityManager->flush();

return $user;
}

Now when we have ability to a create an user with any role which we want and use that user to check permission for any request. When we want to log in the user, we will not actually call the login API endpoint directly, we will just create JWT token for that user and send that token with the each request:

private function clientWithUser(?User $user = null) : Client{
/** @var JWTTokenManagerInterface $tokenManager */
$tokenManager = $this->container->get('lexik_jwt_authentication.jwt_manager');
$token = $tokenManager->create($user);

$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];

if($user){
$headers['Authorization'] = 'Bearer '.$token;
}

$this->client->setDefaultOptions(['headers' => $headers]);

return $this->client;
}

Now when we want to test if some user with role ROLE_SUPER_ADMIN can access some endpoint we will just add:

$user = $this->createUserWithRole('ROLE_SUPER_ADMIN');
$response = $this->clientWithUser($user)->request('GET', '/api/offers');
$this->assertResponseStatusCodeSame(200);

It is just simple as that, now you have a great starting framework for testing permission in the API Platform.

Let’s see how final tests will look like, because we have 3 users and 3 API endpoints so we will have 3 x 3 = 9 tests.

  • Not authenticated users can access only first endpoint
  • Authenticated users can access only first and second endpoint
  • Authenticated users with role ROLE_SUPER_ADMIN can access all there endpoints: first, second and third

The code for that is:

//check first api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/first/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check second api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/second/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check third api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/third/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(403);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

And the full test class is:

<?php

namespace App\Tests\Entity;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Offer;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpKernel\KernelInterface;

class OfferTest extends ApiTestCase
{
private Client $client;
private ContainerInterface $container;
private EntityManagerInterface $entityManager;

protected function setUp(): void
{
$this->client = static::createClient();

$this->container = static::getContainer();

$this->entityManager = $this->container->get('doctrine')->getManager();

$this->initDatabase(self::$kernel);

parent::setUp();
}

public function testGet(): void
{
//create one offer and 2 users (one without role and second with role ROLE_SUPER_ADMIN)
$offer = $this->createOffer();
$userWithoutRole = $this->createUserWithRole();
$userWithRoleSuperAdmin = $this->createUserWithRole('ROLE_SUPER_ADMIN');

//check first api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/first/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check second api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/second/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check third api endpoint (user not authenticated, user authenticated without any role, user authenticate with role ROLE_SUPER_ADMIN)
$url = '/api/third/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(403);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
}

private function initDatabase(KernelInterface $kernel): void
{
$filesystem = new Filesystem();
$filesystem->remove('var/test.db');

$metaData = $this->entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($this->entityManager);
$schemaTool->updateSchema($metaData);
}

private function createOffer()
{
$container = self::getContainer();
$offer = new Offer();
$offer->setContent('test');
$this->entityManager->persist($offer);
$this->entityManager->flush();

return $offer;
}

private function createUserWithRole($roleName = null)
{
$container = self::getContainer();

$hasher = $container->get('security.user_password_hasher');

$user = new User();
$user->setEmail(strtolower($roleName ?? 'no_role') . '@test.com');
$user->setPassword($hasher->hashPassword($user, 'secret'));
if ($roleName) {
$user->setRoles([$roleName]);
}
$this->entityManager->persist($user);
$this->entityManager->flush();

return $user;
}

private function clientWithUser(?User $user = null) : Client{
$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];

if($user){
/** @var JWTTokenManagerInterface $tokenManager */
$tokenManager = $this->container->get('lexik_jwt_authentication.jwt_manager');
$token = $tokenManager->create($user);

$headers['Authorization'] = 'Bearer '.$token;
}

$this->client->setDefaultOptions(['headers' => $headers]);

return $this->client;
}
}

This is just a starting point for your testing framework, the second thing that you want to do is to move your hand method to the parent class. So we will create you custom App/Tests/ApiTestCase.php in which will extend ApiPlatform\Symfony\Bundle\Test\ApiTestCase from the API Platform and move your handy methods there, after that your code will be simple and clean:

<?php

namespace App\Tests\Entity;

use App\Tests\ApiTestCase;

class OfferTestNew extends ApiTestCase
{
public function testGet(): void
{
//create one offer and 2 users (one without role and second with role ROLE_SUPER_ADMIN)
$offer = $this->createOffer();
$userWithoutRole = $this->createUserWithRole();
$userWithRoleSuperAdmin = $this->createUserWithRole('ROLE_SUPER_ADMIN');

//check unauthenticated user
$url = '/api/first/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check authenticated user
$url = '/api/second/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);

//check authenticated user with role ROLE_SUPER_ADMIN
$url = '/api/third/' . $offer->getId();
$response = $this->clientWithUser()->request('GET', $url);
$this->assertResponseStatusCodeSame(401);
$response = $this->clientWithUser($userWithoutRole)->request('GET', $url);
$this->assertResponseStatusCodeSame(403);
$response = $this->clientWithUser($userWithRoleSuperAdmin)->request('GET', $url);
$this->assertResponseStatusCodeSame(200);
}
}

Some of the next steps of building and improving your framework would be:

  • move your hand methods to traits with separated concern and use that trait in you ApiTestCase custom class
  • remove createOffer method and replace it with some more general where you can tell that method to create any kind of the entities by you custom fixtures, maybe Alice fixtures or something similar
  • refactor createUserWithRole function to accept more roles not just one
  • refactor clientWithUser to add more Accept types and Content-Type type or more that to separate method
  • and so on

With each step, you testing framework is better and better and that is great! But we will stop here and that is all for now in this article.

I hope that you enjoyed this article and that was helpful, I will write one more article about complicated cases for API platform security, and I can share my working code at the github if there will be an interests.

--

--

Filip Horvat

Senior Software Engineer, Backend PHP Developer, Located at Croatia, Currently working at myzone.com