Skip to content
This repository was archived by the owner on Sep 16, 2021. It is now read-only.

Commit 7c33217

Browse files
authored
Merge pull request #40 from symfony-cmf/issue-11/security
Added security protection of the API
2 parents c65c7ad + 72d650c commit 7c33217

File tree

8 files changed

+122
-19
lines changed

8 files changed

+122
-19
lines changed

Controller/ResourceController.php

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@
1818
use Symfony\Component\HttpFoundation\Response;
1919
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2020
use Symfony\Component\Routing\Exception\RouteNotFoundException;
21+
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
22+
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
2123
use JMS\Serializer\SerializerInterface;
2224
use JMS\Serializer\SerializationContext;
2325

2426
class ResourceController
2527
{
28+
const ROLE_RESOURCE_READ = 'CMF_RESOURCE_READ';
29+
const ROLE_RESOURCE_WRITE = 'CMF_RESOURCE_WRITE';
30+
2631
/**
2732
* @var RepositoryRegistryInterface
2833
*/
@@ -33,14 +38,20 @@ class ResourceController
3338
*/
3439
private $serializer;
3540

41+
/**
42+
* @var AuthorizationCheckerInterface|null
43+
*/
44+
private $authorizationChecker;
45+
3646
/**
3747
* @param SerializerInterface $serializer
3848
* @param RepositoryRegistryInterface $registry
3949
*/
40-
public function __construct(SerializerInterface $serializer, RepositoryRegistryInterface $registry)
50+
public function __construct(SerializerInterface $serializer, RepositoryRegistryInterface $registry, AuthorizationCheckerInterface $authorizationChecker = null)
4151
{
4252
$this->serializer = $serializer;
4353
$this->registry = $registry;
54+
$this->authorizationChecker = $authorizationChecker;
4455
}
4556

4657
/**
@@ -51,9 +62,15 @@ public function __construct(SerializerInterface $serializer, RepositoryRegistryI
5162
*/
5263
public function getResourceAction($repositoryName, $path)
5364
{
65+
$path = '/'.ltrim($path, '/');
66+
5467
try {
5568
$repository = $this->registry->get($repositoryName);
56-
$resource = $repository->get('/'.$path);
69+
70+
$fullPath = method_exists($repository, 'resolvePath') ? $repository->resolvePath($path) : $path;
71+
$this->guardAccess('read', $repositoryName, $fullPath);
72+
73+
$resource = $repository->get($path);
5774

5875
return $this->createResponse($resource);
5976
} catch (ResourceNotFoundException $e) {
@@ -86,9 +103,12 @@ public function getResourceAction($repositoryName, $path)
86103
*/
87104
public function patchResourceAction($repositoryName, $path, Request $request)
88105
{
106+
$path = '/'.ltrim($path, '/');
89107
$repository = $this->registry->get($repositoryName);
90108

91-
$path = '/'.ltrim($path, '/');
109+
$fullPath = method_exists($repository, 'resolvePath') ? $repository->resolvePath($path) : $path;
110+
$this->guardAccess('write', $repositoryName, $fullPath);
111+
92112

93113
$requestContent = json_decode($request->getContent(), true);
94114
if (!$requestContent) {
@@ -124,9 +144,11 @@ public function patchResourceAction($repositoryName, $path, Request $request)
124144
*/
125145
public function deleteResourceAction($repositoryName, $path)
126146
{
147+
$path = '/'.ltrim($path, '/');
127148
$repository = $this->registry->get($repositoryName);
128149

129-
$path = '/'.ltrim($path, '/');
150+
$fullPath = method_exists($repository, 'resolvePath') ? $repository->resolvePath($path) : $path;
151+
$this->guardAccess('write', $repositoryName, $fullPath);
130152

131153
$repository->remove($path);
132154

@@ -143,6 +165,18 @@ private function badRequestResponse($message)
143165
return $this->createResponse(['message' => $message], Response::HTTP_BAD_REQUEST);
144166
}
145167

168+
private function guardAccess($attribute, $repository, $path)
169+
{
170+
if (null !== $this->authorizationChecker
171+
&& !$this->authorizationChecker->isGranted(
172+
'CMF_RESOURCE_'.strtoupper($attribute),
173+
['repository_name' => $repository, 'path' => $path]
174+
)
175+
) {
176+
throw new AccessDeniedException(sprintf('%s access denied for "%s".', ucfirst($attribute), $path));
177+
}
178+
}
179+
146180
/**
147181
* @param mixed $resource
148182
* @param int $httpStatusCode

Resources/config/resource-rest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<service id="cmf_resource_rest.controller.resource" class="Symfony\Cmf\Bundle\ResourceRestBundle\Controller\ResourceController">
1010
<argument type="service" id="serializer" />
1111
<argument type="service" id="cmf_resource.registry" />
12+
<argument type="service" id="security.authorization_checker" on-invalid="ignore" />
1213
</service>
1314

1415
<service id="cmf_resource_rest.registry.payload_alias" class="Symfony\Cmf\Bundle\ResourceRestBundle\Registry\PayloadAliasRegistry">

Tests/Features/Context/ResourceContext.php

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
use Symfony\Component\Finder\Finder;
2424
use Webmozart\Assert\Assert;
2525

26-
class ResourceContext implements Context, KernelAwareContext
26+
class ResourceContext implements Context
2727
{
2828
private $session;
2929
private $manager;
@@ -33,6 +33,14 @@ class ResourceContext implements Context, KernelAwareContext
3333
*/
3434
private $kernel;
3535

36+
public function __construct()
37+
{
38+
require_once __DIR__.'/../../../vendor/symfony-cmf/testing/bootstrap/bootstrap.php';
39+
require_once __DIR__.'/../../Resources/app/AppKernel.php';
40+
41+
$this->kernel = new \AppKernel('test', true);
42+
}
43+
3644
/**
3745
* Return the path of the configuration file used by the AppKernel.
3846
*
@@ -45,14 +53,6 @@ public static function getConfigurationFile()
4553
return __DIR__.'/../../Resources/app/cache/resource.yml';
4654
}
4755

48-
/**
49-
* {@inheritdoc}
50-
*/
51-
public function setKernel(KernelInterface $kernel)
52-
{
53-
$this->kernel = $kernel;
54-
}
55-
5656
/**
5757
* @BeforeScenario
5858
*/
@@ -64,6 +64,8 @@ public function beforeScenario(BeforeScenarioScope $scope)
6464

6565
$this->clearDiCache();
6666

67+
$this->kernel->boot();
68+
6769
$this->manager = $this->kernel->getContainer()->get('doctrine_phpcr.odm.document_manager');
6870
$this->session = $this->manager->getPhpcrSession();
6971

@@ -79,6 +81,7 @@ public function beforeScenario(BeforeScenarioScope $scope)
7981
public function refreshSession()
8082
{
8183
$this->session->refresh(true);
84+
$this->kernel->shutdown();
8285
}
8386

8487
/**

Tests/Features/security.feature

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
Feature: Security
2+
In order to deny API access to private files
3+
As a developer
4+
I need to be able to write security voters
5+
6+
Background:
7+
Given the test application has the following configuration:
8+
"""
9+
cmf_resource:
10+
repositories:
11+
security:
12+
type: doctrine_phpcr
13+
basepath: /tests/cmf/articles
14+
"""
15+
And there exists an "Article" document at "/private/foo":
16+
| title | Article 1 |
17+
| body | This is my article |
18+
19+
Scenario: Retrieve a protected resource
20+
When I send a GET request to "/api/security/private/foo"
21+
Then the response code should be 401
22+
23+
Scenario: Retrieve a protected non-existent resource
24+
When I send a GET request to "/api/security/private/bar"
25+
Then the response code should be 401
26+
27+
Scenario: Remove a protected resource
28+
When I send a DELETE request to "/api/security/private/admin/something"
29+
Then the response code should be 401
30+
31+
Scenario: Edit a resource
32+
When I send a PATCH request to "/api/security/admin/file"
33+
Then the response code should be 401
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Symfony\Cmf\Bundle\ResourceRestBundle\Tests\Resources\TestBundle\Security;
4+
5+
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
6+
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
7+
use Symfony\Cmf\Bundle\ResourceRestBundle\Controller\ResourceController;
8+
9+
class ResourceVoter extends Voter
10+
{
11+
protected function supports($attribute, $subject)
12+
{
13+
return in_array($attribute, [ResourceController::ROLE_RESOURCE_READ, ResourceController::ROLE_RESOURCE_WRITE]);
14+
}
15+
16+
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
17+
{
18+
if ('security' !== $subject['repository_name']) {
19+
return true;
20+
}
21+
22+
if ('/tests/cmf/articles/public' !== substr($subject['path'], 0, 27)) {
23+
return false;
24+
}
25+
26+
if (ResourceController::ROLE_RESOURCE_WRITE === $attribute) {
27+
return false === strpos($subject['path'], 'admin');
28+
}
29+
30+
return true;
31+
}
32+
}

Tests/Resources/app/config/config.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
* file that was distributed with this source code.
1010
*/
1111

12+
use Symfony\Cmf\Bundle\ResourceRestBundle\Tests\Resources\TestBundle\Security\ResourceVoter;
13+
1214
$container->setParameter('cmf_testing.bundle_fqn', 'Symfony\Cmf\Bundle\ResourceRestBundle');
1315
$loader->import(CMF_TEST_CONFIG_DIR.'/dist/parameters.yml');
1416
$loader->import(CMF_TEST_CONFIG_DIR.'/dist/framework.php');
1517
$loader->import(CMF_TEST_CONFIG_DIR.'/dist/monolog.yml');
1618
$loader->import(CMF_TEST_CONFIG_DIR.'/dist/doctrine.yml');
19+
$loader->import(CMF_TEST_CONFIG_DIR.'/dist/security.yml');
1720
$loader->import(CMF_TEST_CONFIG_DIR.'/phpcr_odm.php');
21+
22+
$container->register('app.resource_voter', ResourceVoter::class)
23+
->addTag('security.voter');

behat.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,5 @@ default:
77
paths:
88
- Tests/Features
99
extensions:
10-
Behat\Symfony2Extension:
11-
kernel:
12-
path: Tests/Resources/app/AppKernel.php
13-
env: behat
14-
bootstrap: vendor/symfony-cmf/testing/bootstrap/bootstrap.php
1510
Behat\WebApiExtension:
1611
base_url: http://localhost:8000/

composer.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"jms/serializer": "^1.2",
2323
"behat/behat": "^3.0.6",
2424
"behat/web-api-extension" : "^1.0@dev",
25-
"behat/symfony2-extension": "^2.0",
2625
"matthiasnoback/symfony-dependency-injection-test": "~0.6",
2726
"matthiasnoback/symfony-config-test": "^1.3.1",
2827
"sonata-project/admin-bundle": "^3.1"

0 commit comments

Comments
 (0)