Passed
Push — master ( 977fa1...0d215e )
by Jan
04:21
created

ElementPermissionListener::preFlushHandler()   B

Complexity

Conditions 8
Paths 9

Size

Total Lines 37
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 8
eloc 19
c 4
b 0
f 0
nc 9
nop 2
dl 0
loc 37
rs 8.4444
1
<?php
2
/**
3
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4
 *
5
 * Copyright (C) 2019 Jan Böhmer (https://github.com/jbtronics)
6
 *
7
 * This program is free software; you can redistribute it and/or
8
 * modify it under the terms of the GNU General Public License
9
 * as published by the Free Software Foundation; either version 2
10
 * of the License, or (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License
18
 * along with this program; if not, write to the Free Software
19
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
20
 */
21
22
namespace App\Security\EntityListeners;
23
24
use App\Entity\Base\DBElement;
25
use App\Entity\UserSystem\User;
26
use App\Security\Annotations\ColumnSecurity;
27
use Doctrine\Common\Annotations\Reader;
28
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
29
use Doctrine\ORM\EntityManagerInterface;
30
use Doctrine\ORM\Event\PreFlushEventArgs;
31
use Doctrine\ORM\Mapping as ORM;
32
use Doctrine\ORM\Mapping\PostLoad;
33
use Doctrine\ORM\Mapping\PreUpdate;
34
use ReflectionClass;
35
use Symfony\Component\HttpKernel\KernelInterface;
36
use Symfony\Component\Security\Core\Security;
37
38
/**
39
 * The purpose of this class is to hook into the doctrine entity lifecycle and restrict access to entity informations
40
 * configured by ColoumnSecurity Annotation.
41
 * If the current programm is running from CLI (like a CLI command), the security checks are disabled.
42
 * (Commands should be able to do everything they like).
43
 *
44
 * If a user does not have access to an coloumn, it will be filled, with a placeholder, after doctrine loading is finished.
45
 * The edit process is also catched, so that these placeholders, does not get saved to database.
46
 */
47
class ElementPermissionListener
48
{
49
    protected $security;
50
    protected $reader;
51
    protected $em;
52
    protected $disabled;
53
54
    protected $perm_cache;
55
    protected $annotation_cache;
56
57
    public function __construct(Security $security, Reader $reader, EntityManagerInterface $em, KernelInterface $kernel)
0 ignored issues
show
Unused Code introduced by
The parameter $kernel is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

57
    public function __construct(Security $security, Reader $reader, EntityManagerInterface $em, /** @scrutinizer ignore-unused */ KernelInterface $kernel)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
58
    {
59
        $this->security = $security;
60
        $this->reader = $reader;
61
        $this->em = $em;
62
        //Disable security when the current program is running from CLI
63
        $this->disabled = $this->isRunningFromCLI();
64
        $this->perm_cache = [];
65
        $this->annotation_cache = [];
66
    }
67
68
    /**
69
     * This function checks if the current script is run from web or from a terminal.
70
     *
71
     * @return bool Returns true if the current programm is running from CLI (terminal)
72
     */
73
    protected function isRunningFromCLI()
74
    {
75
        if (empty($_SERVER['REMOTE_ADDR']) && !isset($_SERVER['HTTP_USER_AGENT']) && \count($_SERVER['argv']) > 0) {
76
            return true;
77
        }
78
79
        return false;
80
    }
81
82
    /**
83
     * Checks if access to the property of the given element is granted.
84
     * This function adds an additional cache layer, where the voters are called only once (to improve performance).
85
     * @param string $mode What operation should be checked. Must be 'read' or 'edit'
86
     * @param ColumnSecurity $annotation The annotation of the property that should be checked
87
     * @param DBElement $element The element that should for which should be checked
88
     * @return bool True if the user is allowed to read that property
89
     */
90
    protected function isGranted(string $mode, ColumnSecurity $annotation, DBElement $element) : bool
91
    {
92
        if ($mode === 'read') {
93
            $operation = $annotation->getReadOperationName();
94
        } elseif ($mode === 'edit') {
95
            $operation = $annotation->getEditOperationName();
96
        } else {
97
            throw new \InvalidArgumentException('$mode must be either "read" or "edit"!');
98
        }
99
100
        //Users must always be checked, because its return value can differ if it is the user itself or something else
101
        if ($element instanceof User) {
102
            return $this->security->isGranted($operation, $element);
103
        }
104
105
        //Check if we have already have saved the permission, otherwise save it to cache
106
        if (!isset($this->perm_cache[$mode][get_class($element)][$operation])) {
107
            $this->perm_cache[$mode][get_class($element)][$operation] = $this->security->isGranted($operation, $element);
108
        }
109
110
        return $this->perm_cache[$mode][get_class($element)][$operation];
111
    }
112
113
    /**
114
     * Gets the ColumnSecurity Annotation for a given and its property.
115
     * Adds an additional cache layer, where AnnotationReader::gerPropertyAnnotation is only called once for a class.
116
     * @param DBElement $element The element for which the annotation should be determined.
117
     * @param \ReflectionProperty $property The property on which the annotation should be determined.
118
     * @return ColumnSecurity|null
119
     */
120
    protected function getAnnotation(DBElement $element, \ReflectionProperty $property) : ?ColumnSecurity
121
    {
122
        if (!isset($this->annotation_cache[get_class($element)][$property->getName()])) {
123
            $this->annotation_cache[get_class($element)][$property->getName()] = $this->reader->getPropertyAnnotation(
124
                $property,
125
                ColumnSecurity::class
126
            );
127
        }
128
129
        return $this->annotation_cache[get_class($element)][$property->getName()];
130
    }
131
132
    /**
133
     * @PostLoad
134
     * @ORM\PostUpdate()
135
     * This function is called after doctrine filled, the entity properties with db values.
136
     * We use this, to check if the user is allowed to access these properties, and if not, we write a placeholder
137
     * into the element properties, so that a user only gets non sensitve data.
138
     *
139
     * This function is also called after an entity was updated, so we dont show the original data to user,
140
     * after an update.
141
     */
142
    public function postLoadHandler(DBElement $element, LifecycleEventArgs $event)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

142
    public function postLoadHandler(DBElement $element, /** @scrutinizer ignore-unused */ LifecycleEventArgs $event)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
143
    {
144
        //Do nothing if security is disabled
145
        if ($this->disabled) {
146
            return;
147
        }
148
149
        //Read Annotations and properties.
150
        $reflectionClass = new ReflectionClass($element);
151
        $properties = $reflectionClass->getProperties();
152
153
        foreach ($properties as $property) {
154
            /**
155
             * @var ColumnSecurity
156
             */
157
            $annotation = $this->getAnnotation($element, $property);
158
159
            //Check if user is allowed to read info, otherwise apply placeholder
160
            if ((null !== $annotation) && !$this->isGranted('read', $annotation, $element)) {
161
                $property->setAccessible(true);
162
                $value = $annotation->getPlaceholder();
163
164
                //Detach placeholder entities, so we dont get cascade errors
165
                if ($value instanceof DBElement) {
166
                    $this->em->detach($value);
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\Common\Persiste...ObjectManager::detach() has been deprecated: Detach operation is deprecated and will be removed in Persistence 2.0. Please use {@see ObjectManager::clear()} instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

166
                    /** @scrutinizer ignore-deprecated */ $this->em->detach($value);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
167
                }
168
169
                $property->setValue($element, $value);
170
            }
171
        }
172
    }
173
174
    /**
175
     * @ORM\PreFlush()
176
     * This function is called before flushing. We use it, to remove all placeholders.
177
     * We do it here and not in preupdate, because this is called before calculating the changeset,
178
     * and so we dont get problems with orphan removal.
179
     */
180
    public function preFlushHandler(DBElement $element, PreFlushEventArgs $eventArgs)
181
    {
182
        //Do nothing if security is disabled
183
        if ($this->disabled) {
184
            return;
185
        }
186
187
        $em = $eventArgs->getEntityManager();
0 ignored issues
show
Unused Code introduced by
The assignment to $em is dead and can be removed.
Loading history...
188
        $unitOfWork = $eventArgs->getEntityManager()->getUnitOfWork();
189
190
        $reflectionClass = new ReflectionClass($element);
191
        $properties = $reflectionClass->getProperties();
192
193
        $old_data = $unitOfWork->getOriginalEntityData($element);
194
195
        foreach ($properties as $property) {
196
            $annotation = $this->getAnnotation($element, $property);
197
198
            $changed = false;
199
200
            //Only set the field if it has an annotation
201
            if (null !== $annotation) {
202
                $property->setAccessible(true);
203
204
                //If the user is not allowed to edit or read this property, reset all values.
205
                if ((!$this->isGranted('read', $annotation, $element)
206
                    || !$this->isGranted('edit', $annotation, $element))) {
207
                    //Set value to old value, so that there a no change to this property
208
                    if (isset($old_data[$property->getName()])) {
209
                        $property->setValue($element, $old_data[$property->getName()]);
210
                        $changed = true;
211
                    }
212
                }
213
214
                if ($changed) {
215
                    //Schedule for update, so the post update method will be called
216
                    $unitOfWork->scheduleForUpdate($element);
217
                }
218
            }
219
        }
220
    }
221
}
222